feat: track events that repeat daily, and each game's dailies

A login campaign is not one job with a deadline. It is twenty small jobs
on twenty separate deadlines, and a day you miss is gone whatever you do
afterwards — which a single "done" tick cannot express.

Repeating events now get a checklist: today's tick, a strip of every day
in the run showing what you got and what you missed, the streak, and how
many chances are left. Past days stay editable, because people tick up
later and a checklist you cannot correct stops being trusted after the
first mistake. Alongside it sits today's dailies — commissions, sanity,
daily training — one tick per game, keyed `dailies:<game>`, since no
source publishes those and they are the only thing on the page that
expires tonight rather than next patch.

Days roll at 04:00 server time per region, not midnight: finishing at
02:00 is still yesterday, and a naive UTC date would tick the wrong box
for four hours every night.

Dailiness is read off what the source published — a login event type, or
"daily"/"check-in"/"7-day" wording — never from a game's habits or an
event's length. That adds no schema field, so the feed contract and every
event ID are untouched. An unannounced end yields a tick count, not a
checklist of invented length, and a tick is never removed except by the
reader, including ticks outside the window the feed now claims: a source
quietly moving a date must not erase a fortnight's streak that exists
nowhere else.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 21:18:22 +02:00
co-authored by Claude Opus 5
parent ad3647bee2
commit ccc5d369bd
11 changed files with 947 additions and 16 deletions
+214
View File
@@ -0,0 +1,214 @@
import type { GachaEvent, GameId, Region } from "./schema.ts";
import { DAY, HOUR, REGION_RESET_UTC_OFFSET } from "./time.ts";
/**
* Events you have to come back to every day.
*
* A login campaign is not one job with a deadline — it is twenty small jobs on
* twenty separate deadlines, and missing one is unrecoverable in a way that
* being late on a story chapter is not. The rest of the app measures a single
* window shrinking; this measures a repeating one, and counts how many are
* left.
*
* Everything here is pure and takes its clock as an argument, for the same
* reason the parsers do: a function that reads `Date.now()` cannot be tested
* against a fixed instant.
*/
/**
* Gacha servers roll the day at 04:00 local server time, not midnight — a
* player finishing at 02:00 is still on the previous day's dailies. Getting
* this wrong ticks the wrong box for four hours every night.
*/
export const RESET_HOUR_LOCAL = 4;
/**
* A 180-day event is already a parse error (see CLAUDE.md § Domain rules), so
* this only ever fires on data that is wrong; it exists so a bad end date
* cannot make the client allocate an unbounded array.
*/
const MAX_DAYS = 200;
/**
* Phrases a source uses for "come back every day". Deliberately narrow: a
* false positive puts a checklist on an event that does not want one, which is
* noise the reader has to work out and dismiss.
*/
const DAILY_PHRASES = [
/\bdaily\b/i,
/\bdailies\b/i,
/\bevery day\b/i,
/\beach day\b/i,
/\bcheck[- ]?in\b/i,
/\bsign[- ]?in\b/i,
/\blog[- ]?in (?:bonus|reward|event|campaign)/i,
/\b7[- ]day\b/i,
/\bconsecutive days?\b/i,
];
/** The fields dailiness is decided from — everything else is irrelevant. */
export type DailyCandidate = Pick<GachaEvent, "type" | "title" | "summary">;
/**
* Whether an event wants a daily checklist.
*
* Read off the event as published. Nothing is inferred from a game's habits or
* an event's length: a source that never says "daily" gets no checklist, which
* is the same skip-rather-than-guess rule the parsers follow.
*/
export function isDaily(event: DailyCandidate): boolean {
if (event.type === "login") return true;
const text = `${event.title} ${event.summary ?? ""}`;
return DAILY_PHRASES.some((re) => re.test(text));
}
/**
* Key for a game's standing daily chore — commissions, sanity, the routine
* that exists whether or not an event is running.
*
* No source publishes these, so they are not feed events and never will be;
* they are a fixed list the client knows about. The two-segment shape cannot
* collide with an event ID, which is always `game:slug:date`, and like every
* other ID here it is a localStorage key: changing it drops a reader's streak
* with nothing server-side to restore from.
*/
export function dailiesId(game: GameId): string {
return `dailies:${game}`;
}
/** Offset from UTC midnight to this region's reset instant. */
function shift(region: Region): number {
return REGION_RESET_UTC_OFFSET[region] * HOUR - RESET_HOUR_LOCAL * HOUR;
}
/**
* Which game-day an instant falls in, as `YYYY-MM-DD`.
*
* These are storage keys, and they are compared with `<` elsewhere in this
* module, so the format is fixed and sortable on purpose.
*/
export function dayKey(ms: number, region: Region): string {
return new Date(ms + shift(region)).toISOString().slice(0, 10);
}
/** The next reset instant strictly after `ms`. */
export function nextResetMs(ms: number, region: Region): number {
const shifted = ms + shift(region);
return Math.floor(shifted / DAY) * DAY + DAY - shift(region);
}
/** How long the reader has left to do today's dailies. */
export function msUntilReset(ms: number, region: Region): number {
return nextResetMs(ms, region) - ms;
}
/**
* Every game-day the event is claimable on, oldest first.
*
* Returns null when the end is unannounced. A daily event with no end date has
* an unknown number of days left, and inventing one to fill a checklist would
* be exactly the fabrication `endsAt: null` exists to prevent.
*/
export function dailyDays(
startsMs: number,
endsMs: number | null,
region: Region,
): string[] | null {
if (endsMs === null) return null;
const out: string[] = [];
// The end instant belongs to the previous day when it lands exactly on a
// reset: an event ending at 04:00 gives you nothing on that final day.
const last = endsMs - 1;
let cursor = startsMs;
if (last < startsMs) return [dayKey(startsMs, region)];
while (cursor <= last && out.length < MAX_DAYS) {
out.push(dayKey(cursor, region));
cursor = nextResetMs(cursor, region);
}
return out;
}
export interface DailySummary {
/** Every claimable day, oldest first. Null when the end is unannounced. */
days: string[] | null;
/** Today's key, whether or not the event is running. */
today: string;
todayInWindow: boolean;
doneToday: boolean;
/** Days ticked off inside the window. */
logged: number;
/** Days still claimable, today included. Null when the end is unannounced. */
remaining: number | null;
/** Past days that went unticked. Null when the end is unannounced. */
missed: number | null;
/** Consecutive ticked days up to today (or up to yesterday, if today is untouched). */
streak: number;
msUntilReset: number;
}
/**
* Where the reader is with a repeating event.
*
* `logged` is the reader's own record and is never second-guessed here: a day
* they ticked stays ticked even if it falls outside the window the feed now
* claims, because a source quietly moving a date must not silently erase what
* somebody did.
*/
export function dailySummary(input: {
startsMs: number;
endsMs: number | null;
region: Region;
now: number;
logged: readonly string[];
}): DailySummary {
const { startsMs, endsMs, region, now, logged } = input;
const days = dailyDays(startsMs, endsMs, region);
const today = dayKey(now, region);
const ticked = new Set(logged);
const inWindow = days === null ? logged.slice() : days.filter((d) => ticked.has(d));
const todayInWindow = days === null ? now >= startsMs : days.includes(today);
return {
days,
today,
todayInWindow,
doneToday: ticked.has(today),
logged: inWindow.length,
remaining: days === null ? null : days.filter((d) => d >= today).length,
missed:
days === null
? null
: days.filter((d) => d < today && !ticked.has(d)).length,
streak: streakOf(logged, today),
msUntilReset: msUntilReset(now, region),
};
}
/**
* Consecutive ticked days ending today.
*
* Counts back from yesterday when today has not been done yet, so a run built
* over a fortnight does not read as broken every morning before the reader has
* logged in.
*
* Works on day keys rather than instants so the standing per-game chores and
* an event's checklist can share one definition of a streak.
*/
export function streakOf(logged: readonly string[], today: string): number {
const ticked = new Set(logged);
const at = Date.parse(`${today}T00:00:00Z`);
let cursor = ticked.has(today) ? at : at - DAY;
let streak = 0;
while (streak < MAX_DAYS && ticked.has(keyOf(cursor))) {
streak += 1;
cursor -= DAY;
}
return streak;
}
function keyOf(ms: number): string {
return new Date(ms).toISOString().slice(0, 10);
}