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
+24
View File
@@ -0,0 +1,24 @@
import { z } from "zod";
import { GameId } from "./schema.ts";
import { SourceHealth } from "./feed.ts";
export const RedeemCodeSchema = z.object({
id: z.string(),
game: GameId,
code: z.string().min(1).max(100),
reward: z.string().max(1000).nullable(),
expiresAt: z.string().datetime().nullable(),
expiresOn: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable()
.optional(),
firstObservedAt: z.string().datetime(),
sourceUrl: z.string().url(),
});
export type RedeemCode = z.infer<typeof RedeemCodeSchema>;
export const CodesFeedSchema = z.object({
schemaVersion: z.literal(1),
codes: z.array(RedeemCodeSchema),
sources: z.array(SourceHealth),
});
export type CodesFeed = z.infer<typeof CodesFeedSchema>;
+19 -15
View File
@@ -1,3 +1,4 @@
import type { ResetTiming } from "./time.ts";
import type { LaneId } from "./custom.ts";
import type { GachaEvent, GameId, Region } from "./schema.ts";
import { DAY, resetShiftMs } from "./time.ts";
@@ -117,19 +118,19 @@ export function dailyOverride(
* generic "what day is it here?" — still gets the regional answer. Anything
* that reads or writes a tick should pass it.
*/
export function dayKey(ms: number, region: Region, game?: LaneId): string {
return new Date(ms + resetShiftMs(region, game)).toISOString().slice(0, 10);
export function dayKey(ms: number, region: Region, game?: LaneId, timing?: ResetTiming): string {
return new Date(ms + resetShiftMs(region, game, timing)).toISOString().slice(0, 10);
}
/** The next reset instant strictly after `ms`. */
export function nextResetMs(ms: number, region: Region, game?: LaneId): number {
const s = resetShiftMs(region, game);
export function nextResetMs(ms: number, region: Region, game?: LaneId, timing?: ResetTiming): number {
const s = resetShiftMs(region, game, timing);
return Math.floor((ms + s) / DAY) * DAY + DAY - s;
}
/** How long the reader has left to do today's dailies. */
export function msUntilReset(ms: number, region: Region, game?: LaneId): number {
return nextResetMs(ms, region, game) - ms;
export function msUntilReset(ms: number, region: Region, game?: LaneId, timing?: ResetTiming): number {
return nextResetMs(ms, region, game, timing) - ms;
}
/**
@@ -144,6 +145,7 @@ export function dailyDays(
endsMs: number | null,
region: Region,
game?: LaneId,
timing?: ResetTiming,
): string[] | null {
if (endsMs === null) return null;
@@ -152,11 +154,11 @@ export function dailyDays(
// reset: an event ending at 04:00 gives you nothing on that final day.
const last = endsMs - 1;
let cursor = startsMs;
if (last < startsMs) return [dayKey(startsMs, region, game)];
if (last < startsMs) return [dayKey(startsMs, region, game, timing)];
while (cursor <= last && out.length < MAX_DAYS) {
out.push(dayKey(cursor, region, game));
cursor = nextResetMs(cursor, region, game);
out.push(dayKey(cursor, region, game, timing));
cursor = nextResetMs(cursor, region, game, timing);
}
return out;
}
@@ -196,9 +198,10 @@ export function catchUpDays(
game: LaneId | undefined,
notBefore: number | null,
span = CATCH_UP_DAYS,
timing?: ResetTiming,
): string[] {
const today = dayKey(now, region, game);
const floor = notBefore === null ? null : dayKey(notBefore, region, game);
const today = dayKey(now, region, game, timing);
const floor = notBefore === null ? null : dayKey(notBefore, region, game, timing);
const out: string[] = [];
// Walked in day-key space rather than in instants, like `streakOf`: these are
@@ -248,10 +251,11 @@ export function dailySummary(input: {
game?: LaneId | undefined;
now: number;
logged: readonly string[];
timing?: ResetTiming;
}): DailySummary {
const { startsMs, endsMs, region, game, now, logged } = input;
const days = dailyDays(startsMs, endsMs, region, game);
const today = dayKey(now, region, game);
const { startsMs, endsMs, region, game, now, logged, timing } = input;
const days = dailyDays(startsMs, endsMs, region, game, timing);
const today = dayKey(now, region, game, timing);
const ticked = new Set(logged);
const inWindow = days === null ? logged.slice() : days.filter((d) => ticked.has(d));
@@ -269,7 +273,7 @@ export function dailySummary(input: {
? null
: days.filter((d) => d < today && !ticked.has(d)).length,
streak: streakOf(logged, today),
msUntilReset: msUntilReset(now, region, game),
msUntilReset: msUntilReset(now, region, game, timing),
};
}
+12 -8
View File
@@ -1,3 +1,4 @@
import type { ResetTiming } from "./time.ts";
import { z } from "zod";
import type { LaneId } from "./custom.ts";
import { dailyDays, dayKey } from "./daily.ts";
@@ -73,7 +74,7 @@ export function readGoal(stored: unknown): Goal | undefined {
export interface GoalStatus {
/** Month and cycle are a task's period (task.ts); an event's goal is a week or the event. */
scope: "week" | "month" | "cycle" | "event";
scope: "day" | "week" | "month" | "cycle" | "event";
measure: "days" | "amount";
/** The period's first game-day: the key an amount is stored under. */
periodStart: string;
@@ -114,11 +115,12 @@ export function goalStatus(input: {
logged: readonly string[];
/** This item's entered numbers, for an amount goal. */
amounts?: AmountLog | undefined;
timing?: ResetTiming;
}): GoalStatus {
const { goal, startsMs, endsMs, region, game, now, logged, amounts } = input;
const today = dayKey(now, region, game);
const first = dayKey(startsMs, region, game);
const run = dailyDays(startsMs, endsMs, region, game);
const { goal, startsMs, endsMs, region, game, now, logged, amounts, timing } = input;
const today = dayKey(now, region, game, timing);
const first = dayKey(startsMs, region, game, timing);
const run = dailyDays(startsMs, endsMs, region, game, timing);
let scope: string[];
let periodStart: string;
@@ -174,9 +176,11 @@ export function goalStatus(input: {
const days = (n: number) => (n === 1 ? "1 day" : `${n} days`);
/** Thousands grouped: 2,100 reads as a number of points, 2100 as a year. */
const num = (n: number) => n.toLocaleString("en-US");
/** " this week", " this month", " this cycle", or nothing for a whole event. */
const within = (s: GoalStatus) => (s.scope === "event" ? "" : ` this ${s.scope}`);
const doneLabel = (s: GoalStatus) => (s.scope === "event" ? "goal reached" : `done this ${s.scope}`);
/** " today", " this week", " this month", " this cycle", or nothing for a whole event. */
const within = (s: GoalStatus) =>
s.scope === "event" ? "" : s.scope === "day" ? " today" : ` this ${s.scope}`;
const doneLabel = (s: GoalStatus) =>
s.scope === "event" ? "goal reached" : s.scope === "day" ? "done today" : `done this ${s.scope}`;
/**
* The checklist's line. An unticked past day is not "missed" here: every day
+6 -1
View File
@@ -31,6 +31,8 @@ const DayKey = z
});
export const PeriodSchema = z.union([
/** Every game-day: a reader's own daily, rolling on its reset. */
z.object({ unit: z.literal("day") }),
z.object({ unit: z.literal("week"), startsOn: WeekdaySchema }),
/** Day of month; 31 means "the last day" in a shorter month. */
z.object({ unit: z.literal("month"), startsOn: z.number().int().min(1).max(31) }),
@@ -73,7 +75,10 @@ export function periodOf(
): { start: string; days: string[] } {
let start: string;
let next: string;
if (period.unit === "week") {
if (period.unit === "day") {
start = today;
next = addDays(today, 1);
} else if (period.unit === "week") {
start = addDays(today, -((isoWeekday(today) - period.startsOn + 7) % 7));
next = addDays(start, 7);
} else if (period.unit === "cycle") {
+82
View File
@@ -0,0 +1,82 @@
import type { Account, ClockRevision } from "../client/state/reader/schema.ts";
import type { Region } from "./schema.ts";
import {
clockFor,
resetHourFor,
serverOffsetUtc,
type Clockable,
type ResetTiming,
} from "./time.ts";
import { dayKey } from "./daily.ts";
export interface ReaderClock extends ResetTiming {
revision: string;
region: Region;
}
export function resolveReaderClock(account: Account, at: number): ReaderClock {
const ordered = account.clocks
.filter((r) => r.historicalOnly !== true)
.slice()
.sort((a, b) => a.effectiveAt.localeCompare(b.effectiveAt));
const r =
ordered.filter((r) => Date.parse(r.effectiveAt) <= at).at(-1) ??
ordered[0]!;
return clockFromRevision(r);
}
export function clockFromRevision(r: ClockRevision): ReaderClock {
return {
revision: r.id,
region: r.region,
offsetUtc: r.automaticBase.offsetUtc,
resetHour: r.reset?.hour ?? r.automaticBase.hour,
resetMinute: r.reset?.minute ?? r.automaticBase.minute,
};
}
export function appendClockRevision(
account: Account,
reset: ClockRevision["reset"],
region: Region,
at: string,
id: string,
): Account {
if (
!Number.isFinite(Date.parse(at)) ||
account.clocks.some((r) => r.id === id) ||
(reset &&
(reset.hour < 0 ||
reset.hour > 23 ||
reset.minute < 0 ||
reset.minute > 59 ||
!Number.isInteger(reset.hour) ||
!Number.isInteger(reset.minute)))
)
throw new Error("Invalid clock revision");
return {
...account,
region,
clocks: [
...account.clocks,
{
id,
effectiveAt: at,
region,
reset,
automaticBase: {
offsetUtc: serverOffsetUtc(region, account.game),
hour: resetHourFor(region, account.game),
minute: 0,
},
},
],
};
}
export function readerDayKey(at: number, clock: ReaderClock): string {
return dayKey(at, clock.region, undefined, clock);
}
export function readerClockFor(
event: Clockable,
account: Account,
now: number,
) {
const clock = resolveReaderClock(account, now);
return clockFor(event, clock.region, now, clock);
}
+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)}`
+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;