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) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-20 06:01:08 +02:00
co-authored by Claude Opus 5
parent 88d5ea2e43
commit bf7959c916
4 changed files with 225 additions and 55 deletions
+86
View File
@@ -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);
});
});