Files
gacha-event-tracker/src/client/components/DailyChecklist.tsx
T
Lucas WintherandClaude Opus 5 3702ee7a4c feat(custom): model reader-authored games and events
The data layer for PRD F13, with no UI and no behaviour change yet.

src/shared/custom.ts defines the two key spaces, their schemas, and the
projection into what the views read. Two properties are the point of it:

- A reader's event id is random, not derived from their title. They can type a
  scraped event's exact name and date, which under ${game}:${slug}:${date} is a
  byte-identical key — one completion mark and one streak silently shared by two
  events. Randomness also means renaming their own event never moves its id.
- A reader's event carries no sourceUrl, so a hand-entered date can never be
  attributed to a source, and claims no region split, because they entered one
  instant and inventing three would fabricate two of them.

The rest is widening what was GameId-shaped into a lane that may be one of
theirs: clockFor takes the boundary fields structurally so their events run on
the identical countdown rather than a second one, day keys fall back to the
regional default for a lane with no server map, and gameMeta becomes a context
resolver so metaFor stays pure and total — a lane can outlive its game when an
import carries an event whose game did not come with it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 18:16:21 +02:00

192 lines
5.8 KiB
TypeScript

import {
dailySummary,
msUntilReset,
type DailySummary,
} from "../../shared/daily.ts";
import type { LaneId } from "../../shared/custom.ts";
import type { Region } from "../../shared/schema.ts";
import { formatRemaining } from "../../shared/time.ts";
/**
* The checklist for an event you have to come back to every day.
*
* A single "done" tick is the wrong shape for these: the job is not finished
* or unfinished, it is finished *today*, and yesterday's is gone whatever you
* do now. So this shows the whole run as a strip of days — what you got, what
* you missed, and how many chances are left — with today's tick as the one
* prominent control.
*
* Past days stay clickable on purpose. People log in and tick up later, and a
* checklist that cannot be corrected stops being trusted after the first
* mistake.
*/
export function DailyChecklist({
startsMs,
endsMs,
region,
game,
now,
logged,
onToggleDay,
}: {
startsMs: number;
endsMs: number | null;
region: Region;
/** Whose reset clock the days are counted on — not every game shares one. */
game: LaneId;
now: number;
logged: string[];
onToggleDay: (day: string) => void;
}) {
const summary = dailySummary({ startsMs, endsMs, region, game, now, logged });
const { days, today, doneToday, todayInWindow } = summary;
const started = now >= startsMs;
return (
<div className="mt-5 rounded-xl border border-hairline p-4">
<div className="flex items-baseline justify-between gap-3">
<p className="eyebrow">Daily checklist</p>
<p className="tnum text-[0.6875rem] text-faint">
resets in {formatRemaining(msUntilReset(now, region, game))}
</p>
</div>
<button
type="button"
onClick={() => onToggleDay(today)}
disabled={!started}
aria-pressed={doneToday}
className={`mt-3 flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors ${
doneToday
? "border-near/60 bg-near/10 text-near"
: "border-hairline text-ink hover:border-faint"
} ${started ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
>
<span
aria-hidden
className={`grid size-6 shrink-0 place-items-center rounded-md border ${
doneToday ? "border-transparent bg-near/25" : "border-hairline"
}`}
>
<svg viewBox="0 0 16 16" className="size-3.5">
<path
d="M2.5 8.5l3.5 3.5 7.5-8"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
opacity={doneToday ? 1 : 0.25}
/>
</svg>
</span>
<span className="text-sm font-medium">
{!started
? "Not started yet"
: doneToday
? "Done today"
: "Do today's"}
</span>
{summary.streak > 1 && (
<span className="ml-auto text-[0.6875rem] text-faint">
{summary.streak}-day streak
</span>
)}
</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>
) : (
<>
<div className="mt-3 flex flex-wrap gap-1">
{days.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">
{caption(summary, todayInWindow)}
</p>
</>
)}
</div>
);
}
/**
* 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.`;
const parts = [`${logged} of ${days.length} days`];
if (remaining > 0) {
parts.push(
todayInWindow
? `${remaining} left including today`
: `${remaining} to come`,
);
} else {
parts.push("the run is over");
}
if (missed !== null && missed > 0) parts.push(`${missed} missed`);
return `${parts.join(" · ")}.`;
}