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
+6 -4
View File
@@ -1,5 +1,6 @@
import type { GameId, Region } from "../../shared/schema.ts";
import { gameMeta } from "../../shared/games.ts";
import type { LaneId } from "../../shared/custom.ts";
import type { Region } from "../../shared/schema.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import type { Prefs } from "../state/usePrefs.ts";
const REGIONS: Array<{ id: Region; label: string }> = [
@@ -17,14 +18,15 @@ export function Controls({
onExport,
onImport,
}: {
games: GameId[];
games: LaneId[];
prefs: Prefs;
onToggleGame: (g: GameId) => void;
onToggleGame: (g: LaneId) => void;
onUpdate: (p: Partial<Prefs>) => void;
ignoredCount: number;
onExport: () => void;
onImport: (file: File) => void;
}) {
const gameMeta = useGameMeta();
return (
<section className="border-t border-hairline px-4 py-5">
<p className="eyebrow">Games</p>
+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),
+3 -2
View File
@@ -3,7 +3,8 @@ import {
msUntilReset,
type DailySummary,
} from "../../shared/daily.ts";
import type { GameId, Region } from "../../shared/schema.ts";
import type { LaneId } from "../../shared/custom.ts";
import type { Region } from "../../shared/schema.ts";
import { formatRemaining } from "../../shared/time.ts";
/**
@@ -32,7 +33,7 @@ export function DailyChecklist({
endsMs: number | null;
region: Region;
/** Whose reset clock the days are counted on — not every game shares one. */
game: GameId;
game: LaneId;
now: number;
logged: string[];
onToggleDay: (day: string) => void;
+15 -9
View File
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { gameMeta } from "../../shared/games.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import { formatAbsolute, formatRemaining } from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx";
import { pressure, pressureReason, type Effort } from "../../shared/effort.ts";
@@ -52,6 +52,7 @@ export function EventDetail({
onNote: (id: string, n: string) => void;
onClose: () => void;
}) {
const gameMeta = useGameMeta();
const { event, clock } = row;
const game = gameMeta(event.game);
const heat = URGENCY_COLOR[clock.urgency];
@@ -217,14 +218,19 @@ export function EventDetail({
>
{completed ? "Mark not done" : "Mark done"}
</button>
<a
href={event.sourceUrl}
target="_blank"
rel="noreferrer noopener"
className="rounded-lg border border-hairline px-4 py-2.5 text-sm text-muted transition-colors hover:text-ink"
>
Source
</a>
{/* Nothing to link to when the reader typed this themselves, and a
dead "Source" button would imply somebody else vouched for the
date. Provenance is stated above instead. */}
{event.sourceUrl !== null && (
<a
href={event.sourceUrl}
target="_blank"
rel="noreferrer noopener"
className="rounded-lg border border-hairline px-4 py-2.5 text-sm text-muted transition-colors hover:text-ink"
>
Source
</a>
)}
</div>
{/* Ignoring is not completing. "Done" keeps an event visible and
+4 -3
View File
@@ -1,5 +1,5 @@
import { gameMeta } from "../../shared/games.ts";
import type { GachaEvent } from "../../shared/schema.ts";
import type { DisplayEvent } from "../../shared/custom.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import {
formatRemaining,
windowCaption,
@@ -10,7 +10,7 @@ import type { Status } from "../state/useProgress.ts";
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
export interface RowEvent {
event: GachaEvent;
event: DisplayEvent;
clock: EventClock;
}
@@ -44,6 +44,7 @@ export function EventRow({
onRestore,
onOpen,
}: EventRowProps) {
const gameMeta = useGameMeta();
const { event, clock } = row;
const game = gameMeta(event.game);
const heat = URGENCY_COLOR[clock.urgency];
+8 -7
View File
@@ -1,5 +1,5 @@
import { gameMeta } from "../../shared/games.ts";
import type { GameId } from "../../shared/schema.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import type { LaneId } from "../../shared/custom.ts";
/**
* One game at a time.
@@ -24,16 +24,17 @@ export function GameFocus({
onAdvance,
}: {
/** Games the reader has switched on, in feed order. */
games: GameId[];
focus: GameId | null;
games: LaneId[];
focus: LaneId | null;
/** Outstanding rows per game, so a chip says whether it is worth a visit. */
counts: Partial<Record<GameId, number>>;
counts: Partial<Record<LaneId, number>>;
total: number;
/** Where "next" goes — null means back to all games. */
next: GameId | null;
onFocus: (game: GameId | null) => void;
next: LaneId | null;
onFocus: (game: LaneId | null) => void;
onAdvance: () => void;
}) {
const gameMeta = useGameMeta();
// With one game there is nothing to focus down to, and the bar would just be
// a chip that does nothing.
if (games.length < 2) return null;
+2 -1
View File
@@ -1,4 +1,4 @@
import { gameMeta } from "../../shared/games.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import { formatRemaining } from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx";
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
@@ -26,6 +26,7 @@ export function NextUp({
focused: string | null;
onOpen: (id: string) => void;
}) {
const gameMeta = useGameMeta();
if (row === null) {
return (
<section className="border-b border-hairline px-4 py-8">
+4 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import { gameMeta } from "../../shared/games.ts";
import type { GameId } from "../../shared/schema.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import type { LaneId } from "../../shared/custom.ts";
import { DAY } from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx";
import { URGENCY_COLOR } from "./Meter.tsx";
@@ -42,6 +42,7 @@ export function Timeline({
*/
isDone: (id: string) => boolean;
}) {
const gameMeta = useGameMeta();
const scroller = useRef<HTMLDivElement>(null);
const ends = rows.map((r) => r.clock.endsMs ?? r.clock.startsMs + 14 * DAY);
@@ -75,7 +76,7 @@ export function Timeline({
);
}
const byGame = new Map<GameId, RowEvent[]>();
const byGame = new Map<LaneId, RowEvent[]>();
for (const row of rows) {
byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]);
}
+7 -6
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { gameMeta } from "../../shared/games.ts";
import type { GameId } from "../../shared/schema.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import type { LaneId } from "../../shared/custom.ts";
/**
* First run: pick your games.
@@ -15,12 +15,13 @@ export function Welcome({
available,
onConfirm,
}: {
available: GameId[];
onConfirm: (chosen: GameId[]) => void;
available: LaneId[];
onConfirm: (chosen: LaneId[]) => void;
}) {
const [chosen, setChosen] = useState<GameId[]>([]);
const gameMeta = useGameMeta();
const [chosen, setChosen] = useState<LaneId[]>([]);
const toggle = (id: GameId) =>
const toggle = (id: LaneId) =>
setChosen((prev) =>
prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id],
);