feat(custom): model reader-authored games and events

The data layer for PRD F13, with no UI and no behaviour change yet.

src/shared/custom.ts defines the two key spaces, their schemas, and the
projection into what the views read. Two properties are the point of it:

- A reader's event id is random, not derived from their title. They can type a
  scraped event's exact name and date, which under ${game}:${slug}:${date} is a
  byte-identical key — one completion mark and one streak silently shared by two
  events. Randomness also means renaming their own event never moves its id.
- A reader's event carries no sourceUrl, so a hand-entered date can never be
  attributed to a source, and claims no region split, because they entered one
  instant and inventing three would fabricate two of them.

The rest is widening what was GameId-shaped into a lane that may be one of
theirs: clockFor takes the boundary fields structurally so their events run on
the identical countdown rather than a second one, day keys fall back to the
regional default for a lane with no server map, and gameMeta becomes a context
resolver so metaFor stays pure and total — a lane can outlive its game when an
import carries an event whose game did not come with it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-17 18:16:21 +02:00
co-authored by Claude Opus 5
parent 4029885833
commit 3702ee7a4c
18 changed files with 640 additions and 68 deletions
+10 -5
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts";
import { gameMeta } from "../../shared/games.ts";
import type { GachaEvent, GameId, Region } from "../../shared/schema.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import type { DisplayEvent, LaneId } from "../../shared/custom.ts";
import type { GameId, Region } from "../../shared/schema.ts";
import { formatRemaining } from "../../shared/time.ts";
import { Fireworks } from "./Fireworks.tsx";
@@ -29,23 +30,27 @@ export function Dailies({
daysFor,
onToggleDay,
}: {
games: GameId[];
games: LaneId[];
/**
* Live events that repeat daily — detected, or marked by the reader — and
* that the reader has not already finished or ignored. An event they marked
* done has no line left to tick, and listing it is the app arguing with them.
*/
events: GachaEvent[];
events: DisplayEvent[];
region: Region;
now: number;
daysFor: (id: string) => string[];
onToggleDay: (id: string, day: string) => void;
}) {
const gameMeta = useGameMeta();
// Each game rolls on its own server clock, so "today" is asked per game
// rather than once for the section — Endfield's European day can still be
// yesterday's while every HoYo game has already turned over.
// Only tracked games have a standing chore — a lane the reader invented has
// no routine we could name for them (docs/DATA-MODEL.md § Reader-authored key
// spaces), so App passes tracked lanes here and this stays a total mapping.
const chores = games.map((id) => ({
key: dailiesId(id),
key: dailiesId(id as GameId),
game: gameMeta(id),
today: dayKey(now, region, id),
resetsIn: msUntilReset(now, region, id),