feat: the redesigned Event Clock

The new reader app replaces the old UI: Checklist with a rail (focus
pills, Next to expire, today's dailies, pinned routines), Timeline in a
new and a classic look, Routines, Archive and a Settings area, in a
Structured (default) and a Relaxed interface. It follows the approved
Round 6 B and Round 7 mockups, corrected screen by screen with the owner.

Reader data moves to a v2 store (gacha-tracker:v2:*). Existing v1 data
is migrated on first load, a recovery copy is written first, and the v1
keys are never removed, so a rollback finds them unchanged. Redeem codes
for Genshin arrive as their own feed.

Also: live's urgency ramp, editable per theme; completion and
section-complete fireworks; a returning-user tour; the design mockups,
specs, plans, reviews and handoff record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-18 01:55:16 +02:00
1 parent ddd9673f86
commit 50c6e91b00
284 files changed
+45100 -1628

No files matched your search

@@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import { freshness } from "../../../shared/feed.ts";
import type { CodesFeed } from "../../../shared/codes.ts";
import type { ReaderState } from "../../state/reader/schema.ts";
import { newCodes } from "../../state/reader/codes.ts";
import { actionMeta } from "../../state/reader/actions.ts";
import { formatInZone } from "../../state/displayTime.ts";
import type { ActionHandler } from "./GameSettings.tsx";
export function CodesPage({
game,
feed,
state,
onAction,
}: {
game: string;
feed: CodesFeed | null;
state: ReaderState;
onAction: ActionHandler;
}) {
const [message, setMessage] = useState("");
const accounts = Object.values(state.accounts).filter(
(a) => a.game === game && !a.retiredAt,
);
const codes = feed?.codes.filter((c) => c.game === game) ?? [];
const unseen = feed ? newCodes(feed, state.codeSeen[game] ?? [], game) : [];
useEffect(() => {
if (unseen.length)
void onAction({
type: "seen",
kind: "codeSeen",
id: game,
ids: unseen.map((c) => c.id),
...actionMeta(),
});
}, [game, feed, onAction]);
return (
<section className="codes-page">
<h2>Redeem codes</h2>
<p className="help">
New-code notices are separate from redemption. Mark codes redeemed for
each account you play.
</p>
<p role="status">{message}</p>
{feed &&
freshness(
feed.sources.filter((s) => s.game === game),
Date.now(),
).stale.length > 0 && (
<p className="help">
Code data may be out of date. Check the linked source before relying
on a code's availability.
</p>
)}
{!feed ? (
<p>The codes feed is unavailable. Event tracking is still available.</p>
) : !codes.length ? (
<p>No published codes are available for this game.</p>
) : (
codes.map((c) => (
<article className="code-row" key={c.id}>
<div>
<code>{c.code}</code>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(c.code);
setMessage(`Copied ${c.code}.`);
} catch {
setMessage(
"Could not copy automatically. Select the code to copy it.",
);
}
}}
>
Copy
</button>
{c.reward && <p>{c.reward}</p>}
<small>
{c.expiresAt
? `Expires ${formatInZone(c.expiresAt, true, typeof state.prefs.timezone === "string" ? state.prefs.timezone : null)}`
: c.expiresOn
? `Expires on ${c.expiresOn} (time not stated)`
: "Expiry not announced"}{" "}
·{" "}
<a href={c.sourceUrl} target="_blank" rel="noreferrer">
Source
</a>
</small>
</div>
<div>
{accounts.map((a) => (
<label key={a.id}>
<input
type="checkbox"
checked={Boolean(state.codeRedeemed[a.id]?.[c.id])}
onChange={(e) =>
void onAction({
type: "redeem",
accountId: a.id,
codeId: c.id,
redeemed: e.target.checked,
...actionMeta(),
})
}
/>
Redeemed · {a.region}
</label>
))}
</div>
</article>
))
)}
</section>
);
}