feat: let a day already gone be ticked

People play at midnight and tick at breakfast, or forget for a week and come
back. A log that only accepts today goes wrong on its first bad evening and is
never trusted again.

An event whose end was published already allowed this — its checklist draws the
whole run and past pips are clickable. `catchUpDays` answers the two cases
`dailyDays` cannot: a standing chore, which has no start or end because it is a
routine rather than an event, and an event with `endsAt: null`, where the
checklist could draw no run at all and so offered only today. That second one
is the worse of the two, because it looks like a working checklist.

Never a day past today. A tick claims you did it, and nobody can have done
tomorrow, so a future day is absent rather than rendered and disabled — a
control for a claim that cannot be true. An event's strip starts when the event
did, capped at the same fortnight so a login campaign that opened in March does
not become a wall of pips.

The window bounds display and never data: a tick older than it stays logged and
keeps counting toward the streak. `DayPip` moves to its own module rather than
being copied, so there is one answer to what a missed day looks like.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-08-20 06:01:08 +02:00
1 parent 88d5ea2e43
commit bf7959c916
4 files changed
+225 -55

No files matched your search

+53
View File
@@ -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;