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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-18 01:55:16 +02:00
co-authored by Claude Opus 5.5
parent ddd9673f86
commit 50c6e91b00
284 changed files with 45100 additions and 1628 deletions
+69
View File
@@ -0,0 +1,69 @@
import {
asDisplayEvent,
asOccurrenceEvent,
CustomEvent,
type DisplayEvent,
} from "../../../shared/custom.ts";
import { ruleIdOf, nextOccurrences } from "../../../shared/recurrence.ts";
import { endingSoonestFirst } from "../../../shared/time.ts";
import {
readerClockFor,
resolveReaderClock,
} from "../../../shared/readerClock.ts";
import type { RowEvent } from "../../components/EventRow.tsx";
import type { Region } from "../../../shared/schema.ts";
import type { ReaderState } from "./schema.ts";
export type ReaderEventRow = RowEvent & {
accountId: string;
region: Region;
clockRevision: string;
};
export function readerEventRows(
state: ReaderState,
events: readonly DisplayEvent[],
now: number,
): ReaderEventRow[] {
const source = new Map(
events.filter((e) => e.status === "published").map((e) => [e.id, e]),
);
for (const raw of Object.values(state.customEvents)) {
const p = CustomEvent.safeParse(raw);
if (!p.success) continue;
const e = p.data;
if (e.repeat === null) source.set(e.id, asDisplayEvent(e));
else
for (const o of nextOccurrences(e, now, 2))
source.set(o.id, asOccurrenceEvent(e, o));
}
const out: ReaderEventRow[] = [];
for (const a of Object.values(state.accounts)) {
if (
a.retiredAt ||
!a.intent.events ||
(Array.isArray(state.prefs.hiddenGames) &&
state.prefs.hiddenGames.includes(a.game))
)
continue;
const scoped = new Map(source);
for (const seen of Object.values(state.seenEvents))
if (
seen.accountId === a.id &&
!scoped.has(seen.event.id) &&
(seen.event.sourceUrl !== null ||
state.customEvents[ruleIdOf(seen.event.id)])
)
scoped.set(seen.event.id, seen.event);
for (const event of scoped.values()) {
if (event.game !== a.game) continue;
const clock = readerClockFor(event, a, now);
out.push({
event,
clock,
accountId: a.id,
region: a.region,
clockRevision: resolveReaderClock(a, now).revision,
});
}
}
return out.sort(endingSoonestFirst);
}