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
+36
View File
@@ -0,0 +1,36 @@
import { createContext, useContext } from "react";
import type { CustomGames, LaneId } from "../../shared/custom.ts";
import { metaFor, type GameMeta } from "../../shared/games.ts";
/**
* How a component turns a lane id into a name, a short label and a hue.
*
* It used to be a direct import of `gameMeta`, which could only answer for the
* games in `GAMES`. Now a lane can also be one the reader invented (PRD F13),
* and those live in their browser rather than in a module — so the resolver has
* to come from somewhere with access to that state.
*
* A context rather than module-level mutable state: `metaFor` stays pure and
* takes the reader's games as an argument, and nothing renders off a registry
* that some other part of the app has been quietly writing to.
*/
export type MetaResolver = (id: LaneId) => GameMeta;
const NO_CUSTOM_GAMES: CustomGames = {};
const GameMetaContext = createContext<MetaResolver>((id) =>
metaFor(id, NO_CUSTOM_GAMES),
);
export const GameMetaProvider = GameMetaContext.Provider;
/**
* The lane resolver for this tree.
*
* The default answers for tracked games only, which is the correct answer
* anywhere the reader's own games cannot appear — and a safe one everywhere
* else, since `metaFor` is total.
*/
export function useGameMeta(): MetaResolver {
return useContext(GameMetaContext);
}
+10 -10
View File
@@ -1,4 +1,4 @@
import type { GameId } from "../../shared/schema.ts";
import type { LaneId } from "../../shared/custom.ts";
/**
* Which rows each part of the page gets to see.
@@ -13,7 +13,7 @@ import type { GameId } from "../../shared/schema.ts";
/** The shape every lens here needs. Structural so this module stays cheap. */
interface Row {
event: { id: string; game: GameId };
event: { id: string; game: LaneId };
clock: { msRemaining: number | null };
}
@@ -71,9 +71,9 @@ export function firstToExpire<T extends Row>(rows: readonly T[]): T | null {
* whose reason is a setting two screens away.
*/
export function resolveFocus(
focus: GameId | null,
enabled: readonly GameId[],
): GameId | null {
focus: LaneId | null,
enabled: readonly LaneId[],
): LaneId | null {
return focus !== null && enabled.includes(focus) ? focus : null;
}
@@ -85,9 +85,9 @@ export function resolveFocus(
* of the rotation except finding the "all" chip again.
*/
export function advanceFocus(
focus: GameId | null,
enabled: readonly GameId[],
): GameId | null {
focus: LaneId | null,
enabled: readonly LaneId[],
): LaneId | null {
if (enabled.length === 0) return null;
const at = focus === null ? -1 : enabled.indexOf(focus);
// An unknown focus (switched-off game) restarts the rotation rather than
@@ -98,8 +98,8 @@ export function advanceFocus(
/** How many rows each game still has outstanding, for the focus chips. */
export function countByGame<T extends Row>(
rows: readonly T[],
): Partial<Record<GameId, number>> {
const out: Partial<Record<GameId, number>> = {};
): Partial<Record<LaneId, number>> {
const out: Partial<Record<LaneId, number>> = {};
for (const row of rows) {
out[row.event.game] = (out[row.event.game] ?? 0) + 1;
}
+5 -4
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import type { GameId, Region } from "../../shared/schema.ts";
import type { LaneId } from "../../shared/custom.ts";
import type { Region } from "../../shared/schema.ts";
import { guessRegion } from "../../shared/time.ts";
import type { SortMode } from "./sort.ts";
import { KEYS, readJson, writeJson } from "./storage.ts";
@@ -7,7 +8,7 @@ import { KEYS, readJson, writeJson } from "./storage.ts";
export interface Prefs {
region: Region;
/** Games the reader has switched off. Stored as hidden so a newly added game shows up by default. */
hiddenGames: GameId[];
hiddenGames: LaneId[];
/**
* One game to look at right now, or null for all of them.
*
@@ -16,7 +17,7 @@ export interface Prefs {
* obeyed (`resolveFocus`), so it can never leave the reader on a blank page
* with no visible cause.
*/
focusGame: GameId | null;
focusGame: LaneId | null;
/** How the list is ordered. Deadline order is the default and the fallback. */
sort: SortMode;
/**
@@ -66,7 +67,7 @@ export function usePrefs() {
setPrefs((prev) => ({ ...prev, ...patch }));
}, []);
const toggleGame = useCallback((game: GameId) => {
const toggleGame = useCallback((game: LaneId) => {
setPrefs((prev) => ({
...prev,
hiddenGames: prev.hiddenGames.includes(game)