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

+20 -2
View File
@@ -1,3 +1,4 @@
import type { ResetTiming } from "./time.ts";
import { z } from "zod";
import type { LaneId } from "./custom.ts";
import { dayKey } from "./daily.ts";
@@ -55,6 +56,20 @@ export const TaskSchema = z.object({
measure: MeasureSchema,
/** The built-in routine this was copied from, when it was (phase 2b). */
from: z.string().optional(),
/**
* Its own reset hour, in server time, instead of the game's. Moves only this
* routine's day boundary; its log stays under the account's clock revision.
*/
resetHour: z.number().int().min(0).max(23).optional(),
note: z.string().trim().max(500).optional(),
/** Rendered as a link, so only web addresses. */
link: z
.string()
.trim()
.max(500)
.url()
.refine((u) => /^https?:\/\//i.test(u))
.optional(),
at: z.string(),
updatedAt: z.string(),
});
@@ -74,9 +89,10 @@ export function taskStatus(input: {
now: number;
logged: readonly string[];
amounts?: AmountLog | undefined;
timing?: ResetTiming;
}): GoalStatus {
const { task, region, now, logged, amounts } = input;
const today = dayKey(now, task.clock ?? region, task.game as LaneId);
const today = dayKey(now, task.clock ?? region, task.game as LaneId, task.clock === undefined ? input.timing : undefined);
const { start, days } = periodOf(task.period, today);
const scope = task.period.unit;
const left = days.filter((k) => k >= today);
@@ -123,7 +139,9 @@ export function asOfLabel(day: string): string {
/** One line saying when it rolls and what it wants. */
export function taskCaption(task: Pick<Task, "period" | "measure">): string {
const when =
task.period.unit === "week"
task.period.unit === "day"
? "every day"
: task.period.unit === "week"
? `every week from ${WEEKDAYS[task.period.startsOn - 1]}`
: task.period.unit === "month"
? `every month from the ${ordinal(task.period.startsOn)}`