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
+11 -7
View File
@@ -8,6 +8,8 @@ import type { GameId, Precision } from "./schema.ts";
* how long is left, how far through a window we are, and how alarmed to be.
*/
export interface ResetTiming { offsetUtc: number; resetHour: number; resetMinute: number }
export const MINUTE = 60_000;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
@@ -86,16 +88,16 @@ export function resetHourFor(region: Region, game?: LaneId): number {
* one, but it is still the reader's streak moving under them. Treat a change
* here as a data change, not a constant.
*/
export function resetShiftMs(region: Region, game?: LaneId): number {
return serverOffsetUtc(region, game) * HOUR - resetHourFor(region, game) * HOUR;
export function resetShiftMs(region: Region, game?: LaneId, timing?: ResetTiming): number {
return timing === undefined ? serverOffsetUtc(region, game) * HOUR - resetHourFor(region, game) * HOUR : timing.offsetUtc * HOUR - timing.resetHour * HOUR - timing.resetMinute * MINUTE;
}
/**
* The instant the game-day labelled `day` (`YYYY-MM-DD`) opens on — the inverse
* of `dayKey`.
*/
export function dayStartMs(day: string, region: Region, game?: LaneId): number {
return Date.parse(`${day}T00:00:00.000Z`) - resetShiftMs(region, game);
export function dayStartMs(day: string, region: Region, game?: LaneId, timing?: ResetTiming): number {
return Date.parse(`${day}T00:00:00.000Z`) - resetShiftMs(region, game, timing);
}
export function guessRegion(
@@ -191,11 +193,12 @@ function boundaryMs(
precision: Precision,
event: Pick<Clockable, "game" | "extractionMethod">,
region: Region,
timing?: ResetTiming,
): number {
if (precision !== "day" || event.extractionMethod !== "parser") {
return Date.parse(iso);
}
return dayStartMs(iso.slice(0, 10), region, event.game);
return dayStartMs(iso.slice(0, 10), region, event.game, timing);
}
/**
@@ -308,10 +311,11 @@ export function clockFor(
event: Clockable,
region: Region,
now: number,
timing?: ResetTiming,
): EventClock {
const startsMs = boundaryMs(event.startsAt, event.startPrecision, event, region);
const startsMs = boundaryMs(event.startsAt, event.startPrecision, event, region, timing);
const end = endBoundary(event, region);
const endsMs = end === null ? null : boundaryMs(end.iso, end.precision, event, region);
const endsMs = end === null ? null : boundaryMs(end.iso, end.precision, event, region, timing);
const msRemaining = endsMs === null ? null : endsMs - now;
const upcoming = now < startsMs;