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. */}
+
+ 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);
+ });
+});