diff --git a/src/client/components/DailyChecklist.tsx b/src/client/components/DailyChecklist.tsx index 81a11d9..cda20b5 100644 --- a/src/client/components/DailyChecklist.tsx +++ b/src/client/components/DailyChecklist.tsx @@ -1,9 +1,12 @@ import { + CATCH_UP_DAYS, + catchUpDays, dailySummary, msUntilReset, type DailySummary, } from "../../shared/daily.ts"; import type { LaneId } from "../../shared/custom.ts"; +import { DayPip } from "./DayPip.tsx"; import type { Region } from "../../shared/schema.ts"; import { formatRemaining } from "../../shared/time.ts"; @@ -95,10 +98,30 @@ export function DailyChecklist({ {days === null ? ( -

- The source hasn't announced an end date, so how many days are left is - unknown. Your ticks are still counted — {summary.logged} so far. -

+ <> + {/* No published end means no run to draw, but the days already gone + are just as claimable as an announced event's — and until this + existed they were unreachable, so a reader who forgot to tick + yesterday could never say so. Only the past, and only as far back + as `catchUpDays` goes. */} +
+ {catchUpDays(now, region, game, startsMs).map((day) => ( + onToggleDay(day)} + /> + ))} +
+

+ The source hasn't announced an end date, so how many days are left is + unknown. Your ticks are still counted — {summary.logged} so far, and + the last {CATCH_UP_DAYS} days are above if you did one and forgot to + say. +

+ ) : ( <>
@@ -121,57 +144,6 @@ export function DailyChecklist({ ); } -/** - * One day. Future days are dimmed but not disabled-looking, past misses read as - * empty rather than as an error — a missed daily is information, not a telling - * off. - */ -function DayPip({ - day, - today, - done, - onToggle, -}: { - day: string; - today: string; - done: boolean; - onToggle: () => void; -}) { - const isToday = day === today; - const isFuture = day > today; - // Rendered in UTC on purpose. A day key is a game-day, not an instant, and - // formatting it in the reader's own zone shifts it a day backwards for - // everyone west of UTC — so the pip would read "12" while the label a screen - // reader announces said "Aug 11". - const label = new Date(`${day}T00:00:00Z`).toLocaleDateString(undefined, { - day: "numeric", - month: "short", - timeZone: "UTC", - }); - - return ( - - ); -} - function caption(summary: DailySummary, todayInWindow: boolean): string { const { days, logged, remaining, missed } = summary; if (days === null || remaining === null) return `${logged} days ticked off.`; diff --git a/src/client/components/DayPip.tsx b/src/client/components/DayPip.tsx new file mode 100644 index 0000000..767cbd3 --- /dev/null +++ b/src/client/components/DayPip.tsx @@ -0,0 +1,59 @@ +/** + * One game-day, ticked or not. + * + * Shared by the two places a run of days is drawn: an event's checklist in the + * detail sheet, and the catch-up strips on the dailies section and on an event + * whose end was never announced. One pip rather than two, because they mean the + * same thing to the reader and a second copy is a second set of rules about + * what a missed day looks like. + * + * Future days are dimmed but not disabled-looking, and past misses read as + * empty rather than as an error — a missed daily is information, not a telling + * off. A catch-up strip never contains a future day at all, so there the + * dimming never appears. + */ +export function DayPip({ + day, + today, + done, + onToggle, +}: { + day: string; + today: string; + done: boolean; + onToggle: () => void; +}) { + const isToday = day === today; + const isFuture = day > today; + // Rendered in UTC on purpose. A day key is a game-day, not an instant, and + // formatting it in the reader's own zone shifts it a day backwards for + // everyone west of UTC — so the pip would read "12" while the label a screen + // reader announces said "Aug 11". + const label = new Date(`${day}T00:00:00Z`).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + timeZone: "UTC", + }); + + return ( + + ); +} diff --git a/src/shared/daily.ts b/src/shared/daily.ts index 865bd6e..4773dce 100644 --- a/src/shared/daily.ts +++ b/src/shared/daily.ts @@ -161,6 +161,59 @@ export function dailyDays( return out; } +/** + * How far back a catch-up strip reaches. + * + * A fortnight: long enough to repair a holiday or a bad week, short enough that + * it is still recording what you did rather than reconstructing a month from + * memory. It bounds *display* only — see `catchUpDays`. + */ +export const CATCH_UP_DAYS = 14; + +/** + * The days a reader can still say they did, oldest first. + * + * `dailyDays` answers this for an event whose end was announced, because there + * the whole run is known. Two things it cannot answer: a game's standing chore, + * which has no start and no end because it is a routine rather than an event, + * and an event with `endsAt: null`, where `dailyDays` returns null and the + * checklist has nothing to draw. Both leave the reader with today and no way to + * record yesterday, which is the day they actually did and forgot to tick. + * + * `notBefore` is an instant to clip at — an event's start — or null when there + * is nothing to clip at, which is the chore case. Never returns a day past + * today: a tick is a claim you did it, and tomorrow is not something anyone can + * have done, so it is absent rather than present-and-disabled. + * + * **This bounds what is shown and never what is stored.** A tick older than the + * window stays in the log, keeps counting toward `streakOf` and toward + * `dailySummary`'s totals, and is simply off-screen — nothing here removes a day + * the reader did not remove themselves. + */ +export function catchUpDays( + now: number, + region: Region, + game: LaneId | undefined, + notBefore: number | null, + span = CATCH_UP_DAYS, +): string[] { + const today = dayKey(now, region, game); + const floor = notBefore === null ? null : dayKey(notBefore, region, game); + + const out: string[] = []; + // Walked in day-key space rather than in instants, like `streakOf`: these are + // keys cut on a game's reset clock, and stepping a calendar day back is the + // only operation that keeps them lined up with what was written. + let cursor = Date.parse(`${today}T00:00:00Z`); + while (out.length < span) { + const key = keyOf(cursor); + if (floor !== null && key < floor) break; + out.push(key); + cursor -= DAY; + } + return out.reverse(); +} + export interface DailySummary { /** Every claimable day, oldest first. Null when the end is unannounced. */ days: string[] | null; diff --git a/test/daily.test.ts b/test/daily.test.ts index acb2e4e..44f1b71 100644 --- a/test/daily.test.ts +++ b/test/daily.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; import { + catchUpDays, + CATCH_UP_DAYS, dailiesId, dailyDays, dailyOverride, @@ -408,3 +410,87 @@ describe("a game whose day rolls on a different hour", () => { ]); }); }); + +describe("catchUpDays", () => { + const NOW = at("2026-08-20T12:00:00.000Z"); + + test("the last fortnight, oldest first, ending today", () => { + const days = catchUpDays(NOW, "europe", "genshin", null); + expect(days).toHaveLength(CATCH_UP_DAYS); + expect(days[days.length - 1]).toBe(dayKey(NOW, "europe", "genshin")); + expect(days[0]).toBe(dayKey(NOW - 13 * DAY, "europe", "genshin")); + expect([...days].sort()).toEqual(days); + }); + + test("never a day past today", () => { + // A tick is a claim that you did it. Tomorrow is not a thing a reader can + // have done, so it is not rendered at all rather than rendered and disabled. + const days = catchUpDays(NOW, "europe", "genshin", null); + const today = dayKey(NOW, "europe", "genshin"); + expect(days.filter((d) => d > today)).toEqual([]); + }); + + test("stops at the day the event began", () => { + // An event that opened three days ago has three days to catch up on, not + // fourteen — the days before it existed were never claimable. + const started = NOW - 2 * DAY; + const days = catchUpDays(NOW, "europe", "genshin", started); + expect(days).toEqual([ + dayKey(started, "europe", "genshin"), + dayKey(NOW - DAY, "europe", "genshin"), + dayKey(NOW, "europe", "genshin"), + ]); + }); + + test("a long-running event is still capped at the fortnight", () => { + // A standing login campaign can have opened half a year ago. Its whole + // history would be a wall of pips nobody scrolls, and reconstructing March + // from memory is not recording what you did. + const days = catchUpDays(NOW, "europe", "genshin", NOW - 200 * DAY); + expect(days).toHaveLength(CATCH_UP_DAYS); + }); + + test("an event that has not started yet offers nothing", () => { + expect(catchUpDays(NOW, "europe", "genshin", NOW + 3 * DAY)).toEqual([]); + }); + + test("cut on the game's own reset clock, not the region's", () => { + // Endfield serves Europe off the Americas machine, so its European day + // rolls at 09:00 UTC rather than 03:00 — a strip cut on the wrong clock + // writes a tick under one day key and reads it under another. + const dawn = at("2026-08-20T05:00:00.000Z"); + const generic = catchUpDays(dawn, "europe", undefined, null); + const endfield = catchUpDays(dawn, "europe", "endfield", null); + expect(endfield).not.toEqual(generic); + expect(endfield[endfield.length - 1]).toBe(dayKey(dawn, "europe", "endfield")); + }); + + test("the window bounds what is shown and never what is stored", () => { + // A tick from five weeks ago is off-screen, still logged, and still counted. + // Nothing here removes a day the reader did not remove themselves. + const old = dayKey(NOW - 35 * DAY, "europe", "genshin"); + const days = catchUpDays(NOW, "europe", "genshin", null); + expect(days).not.toContain(old); + + const logged = [old, dayKey(NOW, "europe", "genshin")]; + const summary = dailySummary({ + startsMs: NOW - 40 * DAY, + endsMs: null, + region: "europe", + game: "genshin", + now: NOW, + logged, + }); + expect(summary.logged).toBe(2); + expect(summary.doneToday).toBe(true); + }); + + test("a streak built outside the window still counts", () => { + const today = dayKey(NOW, "europe", "genshin"); + const logged = Array.from({ length: 30 }, (_, i) => + dayKey(NOW - i * DAY, "europe", "genshin"), + ); + expect(streakOf(logged, today)).toBe(30); + expect(catchUpDays(NOW, "europe", "genshin", null)).toHaveLength(CATCH_UP_DAYS); + }); +});