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
+27 -55
View File
@@ -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({
</button>
{days === null ? (
<p className="mt-3 text-xs leading-relaxed text-faint">
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.
</p>
<>
{/* 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
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>
</>
) : (
<>
<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 {
const { days, logged, remaining, missed } = summary;
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>
);
}