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
+25 -53
View File
@@ -1,9 +1,12 @@
import { import {
CATCH_UP_DAYS,
catchUpDays,
dailySummary, dailySummary,
msUntilReset, msUntilReset,
type DailySummary, type DailySummary,
} from "../../shared/daily.ts"; } from "../../shared/daily.ts";
import type { LaneId } from "../../shared/custom.ts"; import type { LaneId } from "../../shared/custom.ts";
import { DayPip } from "./DayPip.tsx";
import type { Region } from "../../shared/schema.ts"; import type { Region } from "../../shared/schema.ts";
import { formatRemaining } from "../../shared/time.ts"; import { formatRemaining } from "../../shared/time.ts";
@@ -95,10 +98,30 @@ export function DailyChecklist({
</button> </button>
{days === null ? ( {days === null ? (
<p className="mt-3 text-xs leading-relaxed text-faint"> <>
{/* 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. */}
<div className="mt-3 flex flex-wrap gap-1">
{catchUpDays(now, region, game, startsMs).map((day) => (
<DayPip
key={day}
day={day}
today={today}
done={logged.includes(day)}
onToggle={() => onToggleDay(day)}
/>
))}
</div>
<p className="mt-2.5 text-xs leading-relaxed text-faint">
The source hasn't announced an end date, so how many days are left is 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. 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.
</p> </p>
</>
) : ( ) : (
<> <>
<div className="mt-3 flex flex-wrap gap-1"> <div className="mt-3 flex flex-wrap gap-1">
@@ -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 (
<button
type="button"
onClick={onToggle}
disabled={isFuture}
aria-pressed={done}
aria-label={`${label}${done ? ", done" : ", not done"}`}
title={label}
className={`tnum size-6 rounded-[5px] border text-[0.625rem] leading-none transition-colors ${
done
? "border-transparent bg-near/25 text-near"
: isFuture
? "border-hairline/60 text-faint/50"
: "border-hairline text-faint hover:border-faint"
} ${isToday ? "ring-1 ring-ink/40" : ""} ${
isFuture ? "cursor-default" : "cursor-pointer"
}`}
>
{day.slice(8)}
</button>
);
}
function caption(summary: DailySummary, todayInWindow: boolean): string { function caption(summary: DailySummary, todayInWindow: boolean): string {
const { days, logged, remaining, missed } = summary; const { days, logged, remaining, missed } = summary;
if (days === null || remaining === null) return `${logged} days ticked off.`; if (days === null || remaining === null) return `${logged} days ticked off.`;
+59
View File
@@ -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 (
<button
type="button"
onClick={onToggle}
disabled={isFuture}
aria-pressed={done}
aria-label={`${label}${done ? ", done" : ", not done"}`}
title={label}
className={`tnum size-6 rounded-[5px] border text-[0.625rem] leading-none transition-colors ${
done
? "border-transparent bg-near/25 text-near"
: isFuture
? "border-hairline/60 text-faint/50"
: "border-hairline text-faint hover:border-faint"
} ${isToday ? "ring-1 ring-ink/40" : ""} ${
isFuture ? "cursor-default" : "cursor-pointer"
}`}
>
{day.slice(8)}
</button>
);
}
+53
View File
@@ -161,6 +161,59 @@ export function dailyDays(
return out; 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 { export interface DailySummary {
/** Every claimable day, oldest first. Null when the end is unannounced. */ /** Every claimable day, oldest first. Null when the end is unannounced. */
days: string[] | null; days: string[] | null;
+86
View File
@@ -1,5 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { import {
catchUpDays,
CATCH_UP_DAYS,
dailiesId, dailiesId,
dailyDays, dailyDays,
dailyOverride, 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);
});
});