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
+4 -3
View File
@@ -26,8 +26,8 @@ import {
} from "./state/lens.ts"; } from "./state/lens.ts";
import { clockFor, DAY, formatRemaining } from "../shared/time.ts"; import { clockFor, DAY, formatRemaining } from "../shared/time.ts";
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
import { gameMeta } from "../shared/games.ts"; import { useGameMeta } from "./state/gameMeta.tsx";
import type { GameId } from "../shared/schema.ts"; import type { LaneId } from "../shared/custom.ts";
type View = "soon" | "calendar"; type View = "soon" | "calendar";
@@ -70,6 +70,7 @@ export function App() {
// The event most recently ignored, so it can be put back without hunting for // The event most recently ignored, so it can be put back without hunting for
// a row that just disappeared. // a row that just disappeared.
const [lastIgnored, setLastIgnored] = useState<{ id: string; title: string } | null>(null); const [lastIgnored, setLastIgnored] = useState<{ id: string; title: string } | null>(null);
const gameMeta = useGameMeta();
const now = useNow(); const now = useNow();
const online = useOnline(); const online = useOnline();
const { prefs, update, toggleGame } = usePrefs(); const { prefs, update, toggleGame } = usePrefs();
@@ -151,7 +152,7 @@ export function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, prefs.region, Math.floor(now / 60_000)]); }, [state, prefs.region, Math.floor(now / 60_000)]);
const games = useMemo<GameId[]>( const games = useMemo<LaneId[]>(
() => [...new Set(allRows.map((r) => r.event.game))], () => [...new Set(allRows.map((r) => r.event.game))],
[allRows], [allRows],
); );
+6 -4
View File
@@ -1,5 +1,6 @@
import type { GameId, Region } from "../../shared/schema.ts"; import type { LaneId } from "../../shared/custom.ts";
import { gameMeta } from "../../shared/games.ts"; import type { Region } from "../../shared/schema.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import type { Prefs } from "../state/usePrefs.ts"; import type { Prefs } from "../state/usePrefs.ts";
const REGIONS: Array<{ id: Region; label: string }> = [ const REGIONS: Array<{ id: Region; label: string }> = [
@@ -17,14 +18,15 @@ export function Controls({
onExport, onExport,
onImport, onImport,
}: { }: {
games: GameId[]; games: LaneId[];
prefs: Prefs; prefs: Prefs;
onToggleGame: (g: GameId) => void; onToggleGame: (g: LaneId) => void;
onUpdate: (p: Partial<Prefs>) => void; onUpdate: (p: Partial<Prefs>) => void;
ignoredCount: number; ignoredCount: number;
onExport: () => void; onExport: () => void;
onImport: (file: File) => void; onImport: (file: File) => void;
}) { }) {
const gameMeta = useGameMeta();
return ( return (
<section className="border-t border-hairline px-4 py-5"> <section className="border-t border-hairline px-4 py-5">
<p className="eyebrow">Games</p> <p className="eyebrow">Games</p>
+10 -5
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts"; import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts";
import { gameMeta } from "../../shared/games.ts"; import { useGameMeta } from "../state/gameMeta.tsx";
import type { GachaEvent, GameId, Region } from "../../shared/schema.ts"; import type { DisplayEvent, LaneId } from "../../shared/custom.ts";
import type { GameId, Region } from "../../shared/schema.ts";
import { formatRemaining } from "../../shared/time.ts"; import { formatRemaining } from "../../shared/time.ts";
import { Fireworks } from "./Fireworks.tsx"; import { Fireworks } from "./Fireworks.tsx";
@@ -29,23 +30,27 @@ export function Dailies({
daysFor, daysFor,
onToggleDay, onToggleDay,
}: { }: {
games: GameId[]; games: LaneId[];
/** /**
* Live events that repeat daily — detected, or marked by the reader — and * 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 * 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. * done has no line left to tick, and listing it is the app arguing with them.
*/ */
events: GachaEvent[]; events: DisplayEvent[];
region: Region; region: Region;
now: number; now: number;
daysFor: (id: string) => string[]; daysFor: (id: string) => string[];
onToggleDay: (id: string, day: string) => void; onToggleDay: (id: string, day: string) => void;
}) { }) {
const gameMeta = useGameMeta();
// Each game rolls on its own server clock, so "today" is asked per game // 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 // rather than once for the section — Endfield's European day can still be
// yesterday's while every HoYo game has already turned over. // 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) => ({ const chores = games.map((id) => ({
key: dailiesId(id), key: dailiesId(id as GameId),
game: gameMeta(id), game: gameMeta(id),
today: dayKey(now, region, id), today: dayKey(now, region, id),
resetsIn: msUntilReset(now, region, id), resetsIn: msUntilReset(now, region, id),
+3 -2
View File
@@ -3,7 +3,8 @@ import {
msUntilReset, msUntilReset,
type DailySummary, type DailySummary,
} from "../../shared/daily.ts"; } 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"; import { formatRemaining } from "../../shared/time.ts";
/** /**
@@ -32,7 +33,7 @@ export function DailyChecklist({
endsMs: number | null; endsMs: number | null;
region: Region; region: Region;
/** Whose reset clock the days are counted on — not every game shares one. */ /** Whose reset clock the days are counted on — not every game shares one. */
game: GameId; game: LaneId;
now: number; now: number;
logged: string[]; logged: string[];
onToggleDay: (day: string) => void; onToggleDay: (day: string) => void;
+7 -1
View File
@@ -1,5 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { gameMeta } from "../../shared/games.ts"; import { useGameMeta } from "../state/gameMeta.tsx";
import { formatAbsolute, formatRemaining } from "../../shared/time.ts"; import { formatAbsolute, formatRemaining } from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx"; import type { RowEvent } from "./EventRow.tsx";
import { pressure, pressureReason, type Effort } from "../../shared/effort.ts"; import { pressure, pressureReason, type Effort } from "../../shared/effort.ts";
@@ -52,6 +52,7 @@ export function EventDetail({
onNote: (id: string, n: string) => void; onNote: (id: string, n: string) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const gameMeta = useGameMeta();
const { event, clock } = row; const { event, clock } = row;
const game = gameMeta(event.game); const game = gameMeta(event.game);
const heat = URGENCY_COLOR[clock.urgency]; const heat = URGENCY_COLOR[clock.urgency];
@@ -217,6 +218,10 @@ export function EventDetail({
> >
{completed ? "Mark not done" : "Mark done"} {completed ? "Mark not done" : "Mark done"}
</button> </button>
{/* 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 <a
href={event.sourceUrl} href={event.sourceUrl}
target="_blank" target="_blank"
@@ -225,6 +230,7 @@ export function EventDetail({
> >
Source Source
</a> </a>
)}
</div> </div>
{/* Ignoring is not completing. "Done" keeps an event visible and {/* 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 { DisplayEvent } from "../../shared/custom.ts";
import type { GachaEvent } from "../../shared/schema.ts"; import { useGameMeta } from "../state/gameMeta.tsx";
import { import {
formatRemaining, formatRemaining,
windowCaption, windowCaption,
@@ -10,7 +10,7 @@ import type { Status } from "../state/useProgress.ts";
import { Meter, URGENCY_COLOR } from "./Meter.tsx"; import { Meter, URGENCY_COLOR } from "./Meter.tsx";
export interface RowEvent { export interface RowEvent {
event: GachaEvent; event: DisplayEvent;
clock: EventClock; clock: EventClock;
} }
@@ -44,6 +44,7 @@ export function EventRow({
onRestore, onRestore,
onOpen, onOpen,
}: EventRowProps) { }: EventRowProps) {
const gameMeta = useGameMeta();
const { event, clock } = row; const { event, clock } = row;
const game = gameMeta(event.game); const game = gameMeta(event.game);
const heat = URGENCY_COLOR[clock.urgency]; const heat = URGENCY_COLOR[clock.urgency];
+8 -7
View File
@@ -1,5 +1,5 @@
import { gameMeta } from "../../shared/games.ts"; import { useGameMeta } from "../state/gameMeta.tsx";
import type { GameId } from "../../shared/schema.ts"; import type { LaneId } from "../../shared/custom.ts";
/** /**
* One game at a time. * One game at a time.
@@ -24,16 +24,17 @@ export function GameFocus({
onAdvance, onAdvance,
}: { }: {
/** Games the reader has switched on, in feed order. */ /** Games the reader has switched on, in feed order. */
games: GameId[]; games: LaneId[];
focus: GameId | null; focus: LaneId | null;
/** Outstanding rows per game, so a chip says whether it is worth a visit. */ /** 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; total: number;
/** Where "next" goes — null means back to all games. */ /** Where "next" goes — null means back to all games. */
next: GameId | null; next: LaneId | null;
onFocus: (game: GameId | null) => void; onFocus: (game: LaneId | null) => void;
onAdvance: () => void; onAdvance: () => void;
}) { }) {
const gameMeta = useGameMeta();
// With one game there is nothing to focus down to, and the bar would just be // With one game there is nothing to focus down to, and the bar would just be
// a chip that does nothing. // a chip that does nothing.
if (games.length < 2) return null; 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 { formatRemaining } from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx"; import type { RowEvent } from "./EventRow.tsx";
import { Meter, URGENCY_COLOR } from "./Meter.tsx"; import { Meter, URGENCY_COLOR } from "./Meter.tsx";
@@ -26,6 +26,7 @@ export function NextUp({
focused: string | null; focused: string | null;
onOpen: (id: string) => void; onOpen: (id: string) => void;
}) { }) {
const gameMeta = useGameMeta();
if (row === null) { if (row === null) {
return ( return (
<section className="border-b border-hairline px-4 py-8"> <section className="border-b border-hairline px-4 py-8">
+4 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { gameMeta } from "../../shared/games.ts"; import { useGameMeta } from "../state/gameMeta.tsx";
import type { GameId } from "../../shared/schema.ts"; import type { LaneId } from "../../shared/custom.ts";
import { DAY } from "../../shared/time.ts"; import { DAY } from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx"; import type { RowEvent } from "./EventRow.tsx";
import { URGENCY_COLOR } from "./Meter.tsx"; import { URGENCY_COLOR } from "./Meter.tsx";
@@ -42,6 +42,7 @@ export function Timeline({
*/ */
isDone: (id: string) => boolean; isDone: (id: string) => boolean;
}) { }) {
const gameMeta = useGameMeta();
const scroller = useRef<HTMLDivElement>(null); const scroller = useRef<HTMLDivElement>(null);
const ends = rows.map((r) => r.clock.endsMs ?? r.clock.startsMs + 14 * DAY); 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) { for (const row of rows) {
byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]); byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]);
} }
+7 -6
View File
@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { gameMeta } from "../../shared/games.ts"; import { useGameMeta } from "../state/gameMeta.tsx";
import type { GameId } from "../../shared/schema.ts"; import type { LaneId } from "../../shared/custom.ts";
/** /**
* First run: pick your games. * First run: pick your games.
@@ -15,12 +15,13 @@ export function Welcome({
available, available,
onConfirm, onConfirm,
}: { }: {
available: GameId[]; available: LaneId[];
onConfirm: (chosen: GameId[]) => void; 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) => setChosen((prev) =>
prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id], prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id],
); );
+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. * 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. */ /** The shape every lens here needs. Structural so this module stays cheap. */
interface Row { interface Row {
event: { id: string; game: GameId }; event: { id: string; game: LaneId };
clock: { msRemaining: number | null }; 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. * whose reason is a setting two screens away.
*/ */
export function resolveFocus( export function resolveFocus(
focus: GameId | null, focus: LaneId | null,
enabled: readonly GameId[], enabled: readonly LaneId[],
): GameId | null { ): LaneId | null {
return focus !== null && enabled.includes(focus) ? focus : 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. * of the rotation except finding the "all" chip again.
*/ */
export function advanceFocus( export function advanceFocus(
focus: GameId | null, focus: LaneId | null,
enabled: readonly GameId[], enabled: readonly LaneId[],
): GameId | null { ): LaneId | null {
if (enabled.length === 0) return null; if (enabled.length === 0) return null;
const at = focus === null ? -1 : enabled.indexOf(focus); const at = focus === null ? -1 : enabled.indexOf(focus);
// An unknown focus (switched-off game) restarts the rotation rather than // 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. */ /** How many rows each game still has outstanding, for the focus chips. */
export function countByGame<T extends Row>( export function countByGame<T extends Row>(
rows: readonly T[], rows: readonly T[],
): Partial<Record<GameId, number>> { ): Partial<Record<LaneId, number>> {
const out: Partial<Record<GameId, number>> = {}; const out: Partial<Record<LaneId, number>> = {};
for (const row of rows) { for (const row of rows) {
out[row.event.game] = (out[row.event.game] ?? 0) + 1; 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 { 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 { guessRegion } from "../../shared/time.ts";
import type { SortMode } from "./sort.ts"; import type { SortMode } from "./sort.ts";
import { KEYS, readJson, writeJson } from "./storage.ts"; import { KEYS, readJson, writeJson } from "./storage.ts";
@@ -7,7 +8,7 @@ import { KEYS, readJson, writeJson } from "./storage.ts";
export interface Prefs { export interface Prefs {
region: Region; region: Region;
/** Games the reader has switched off. Stored as hidden so a newly added game shows up by default. */ /** 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. * 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 * obeyed (`resolveFocus`), so it can never leave the reader on a blank page
* with no visible cause. * with no visible cause.
*/ */
focusGame: GameId | null; focusGame: LaneId | null;
/** How the list is ordered. Deadline order is the default and the fallback. */ /** How the list is ordered. Deadline order is the default and the fallback. */
sort: SortMode; sort: SortMode;
/** /**
@@ -66,7 +67,7 @@ export function usePrefs() {
setPrefs((prev) => ({ ...prev, ...patch })); setPrefs((prev) => ({ ...prev, ...patch }));
}, []); }, []);
const toggleGame = useCallback((game: GameId) => { const toggleGame = useCallback((game: LaneId) => {
setPrefs((prev) => ({ setPrefs((prev) => ({
...prev, ...prev,
hiddenGames: prev.hiddenGames.includes(game) hiddenGames: prev.hiddenGames.includes(game)
+202
View File
@@ -0,0 +1,202 @@
import { z } from "zod";
import { EventType, GachaEvent, Precision, slugify } from "./schema.ts";
/**
* Games and events the reader entered themselves (PRD F13).
*
* No source publishes these, nothing fetches them, and they never enter the
* ingest pipeline — `sanitize.ts` and `merge.ts` exist for pages we do not
* control, and a reader's own typing is neither untrusted markup nor a second
* opinion to reconcile. What they *do* share with scraped events is everything
* downstream: the same lists, the same clock, the same progress, ignore and
* daily-checklist stores, keyed by the ids below.
*/
/**
* Reserved first segments. None of these may ever become a `GameId`.
*
* Every id in this app is colon-separated and the first segment decides which
* space it belongs to: `dailies:<game>` is a standing chore, `mygame:` and
* `myevent:` are the reader's own, and anything else is `<game>:<slug>:<date>`
* from a source. The day one of these becomes a game id is the day two key
* spaces merge silently, so a test pins it against `GameId.options`.
*/
export const RESERVED_ID_SEGMENTS = ["dailies", "mygame", "myevent"] as const;
const GAME_PREFIX = "mygame";
const EVENT_PREFIX = "myevent";
export const CustomGameId = z.string().regex(/^mygame:[a-z0-9-]{1,60}$/);
export const CustomEventId = z.string().regex(/^myevent:[a-z0-9]{6,32}$/);
/**
* Anything that can key a lane: a tracked game or one the reader defined.
*
* Deliberately a plain string rather than a union — it is read by filters,
* focus, counts and day keys, none of which care which kind it is, and a union
* would push a narrowing at every one of those call sites for no safety.
*/
export type LaneId = string;
export function isCustomGameId(id: string): boolean {
return id.startsWith(`${GAME_PREFIX}:`);
}
/**
* Whether this event is the reader's own.
*
* The id is the authority, not `extractionMethod` — that says a human entered
* the value, which is also true of an event approved through the review gate.
* This says *this* reader entered it, which is what the UI must not get wrong:
* their own date is never attributed to a source.
*/
export function isCustomEventId(id: string): boolean {
return id.startsWith(`${EVENT_PREFIX}:`);
}
export const CustomGame = z.object({
id: CustomGameId,
name: z.string().min(1).max(40),
/**
* Reaches a `style` attribute, and an imported file is not necessarily one
* this reader wrote — so the shape is checked rather than trusted.
*/
hue: z.string().regex(/^#[0-9a-fA-F]{6}$/),
at: z.string().datetime(),
});
export type CustomGame = z.infer<typeof CustomGame>;
export const CustomEvent = z
.object({
id: CustomEventId,
/** A tracked game, or one of theirs — a source can miss an event too. */
game: z.string().min(1),
title: z.string().min(1).max(200),
type: EventType,
summary: z.string().max(500).nullable(),
startsAt: z.string().datetime(),
startPrecision: Precision,
endsAt: z.string().datetime().nullable(),
endPrecision: Precision,
at: z.string().datetime(),
updatedAt: z.string().datetime(),
})
// The same invariants the feed is held to. "I don't know when this ends" has
// to be expressible here too, or entering an unannounced event would force
// the reader to invent a date — the one thing this product refuses to do.
.refine((e) => (e.endsAt === null) === (e.endPrecision === "unknown"), {
message: "endsAt null must pair with endPrecision 'unknown'",
path: ["endPrecision"],
})
.refine((e) => e.endsAt === null || e.endsAt > e.startsAt, {
message: "endsAt must be after startsAt",
path: ["endsAt"],
});
export type CustomEvent = z.infer<typeof CustomEvent>;
export const CustomGames = z.record(z.string(), CustomGame);
export const CustomEvents = z.record(z.string(), CustomEvent);
export type CustomGames = z.infer<typeof CustomGames>;
export type CustomEvents = z.infer<typeof CustomEvents>;
/**
* What every view in the client actually reads.
*
* A feed event satisfies this as-is; a reader's event is projected into it by
* `asDisplayEvent`. Only two fields widen, and both for the same reason — the
* reader's events are not from a source:
*
* game may be a lane they invented, so not a `GameId`
* sourceUrl is null, because there is no page to send a sceptic to
*/
export type DisplayEvent = Omit<GachaEvent, "game" | "sourceUrl"> & {
game: LaneId;
sourceUrl: string | null;
};
/**
* Project a reader's event into the shape the views read.
*
* `extractionMethod: "manual"` and `confidence: 1` are the existing vocabulary
* for "a human asserted this", so nothing new is invented to describe it. The
* region fields say false/null because the reader entered one instant, not a
* per-region map — turning one timestamp into three would fabricate two of them.
*/
export function asDisplayEvent(event: CustomEvent): DisplayEvent {
return {
id: event.id,
game: event.game,
title: event.title,
type: event.type,
summary: event.summary,
startsAt: event.startsAt,
startPrecision: event.startPrecision,
endsAt: event.endsAt,
endPrecision: event.endPrecision,
regionScoped: false,
regionEnds: null,
sourceUrl: null,
sourceId: "you",
status: "published",
confidence: 1,
extractionMethod: "manual",
version: 1,
firstSeenAt: event.at,
updatedAt: event.updatedAt,
};
}
/**
* A stable id for a game the reader named, unique among the ones they have.
*
* Slug-derived so it reads in an export, and suffixed on collision rather than
* overwriting — two games called "Nikke" are two games.
*/
export function mintCustomGameId(
name: string,
taken: Iterable<string> = [],
): string {
const base = slugify(name).slice(0, 60) || "game";
const used = new Set(taken);
let id = `${GAME_PREFIX}:${base}`;
for (let n = 2; used.has(id); n += 1) id = `${GAME_PREFIX}:${base}-${n}`;
return id;
}
/**
* A random id for an event the reader entered.
*
* Random, not derived from the title, for two reasons. It cannot collide with
* `${game}:${slug}:${date}` even when they type a scraped event's exact name and
* date — that collision would silently share one completion mark and one streak
* between two events. And it does not move when they rename their own event, so
* editing a typo in a title never costs them the marks attached to it.
*/
export function mintCustomEventId(random: () => number = Math.random): string {
let token = "";
while (token.length < 10) {
token += Math.floor(random() * 36 ** 6)
.toString(36)
.padStart(6, "0");
}
return `${EVENT_PREFIX}:${token.slice(0, 10)}`;
}
/**
* The precision a reader's boundary actually has.
*
* A date typed with no time of day is `"day"`, exactly as a source that printed
* one would be — so the detail sheet's existing "accurate to the day only" note
* is honest about their input too, rather than presenting midnight as a time
* they chose.
*/
export function precisionOf(hasTime: boolean): Extract<Precision, "exact" | "day"> {
return hasTime ? "exact" : "day";
}
/** True when `id` is a game this reader defined and still has. */
export function knownLane(id: LaneId, games: CustomGames): boolean {
return !isCustomGameId(id) || games[id] !== undefined;
}
+13 -8
View File
@@ -1,4 +1,5 @@
import { GAMES } from "./games.ts"; import { GAMES } from "./games.ts";
import type { LaneId } from "./custom.ts";
import type { GachaEvent, GameId, Region } from "./schema.ts"; import type { GachaEvent, GameId, Region } from "./schema.ts";
import { DAY, HOUR, REGION_RESET_UTC_OFFSET } from "./time.ts"; import { DAY, HOUR, REGION_RESET_UTC_OFFSET } from "./time.ts";
@@ -127,8 +128,12 @@ export function dailyOverride(
* that *do* have their own server onto somebody else's clock, which is a * that *do* have their own server onto somebody else's clock, which is a
* different bug in the same place. * different bug in the same place.
*/ */
export function serverOffsetUtc(region: Region, game?: GameId): number { export function serverOffsetUtc(region: Region, game?: LaneId): number {
const override = game === undefined ? undefined : GAMES[game].resetOffsets?.[region]; // A lane the reader invented (PRD F13) has no server map to know about, and
// neither does an id that has outlived its game, so both take the regional
// default rather than being looked up and crashing.
const override =
game === undefined ? undefined : GAMES[game as GameId]?.resetOffsets?.[region];
return override ?? REGION_RESET_UTC_OFFSET[region]; return override ?? REGION_RESET_UTC_OFFSET[region];
} }
@@ -141,7 +146,7 @@ export function serverOffsetUtc(region: Region, game?: GameId): number {
* but it is still the reader's streak moving under them. Treat a change here as * but it is still the reader's streak moving under them. Treat a change here as
* a data change, not a constant. * a data change, not a constant.
*/ */
function shift(region: Region, game?: GameId): number { function shift(region: Region, game?: LaneId): number {
return serverOffsetUtc(region, game) * HOUR - RESET_HOUR_LOCAL * HOUR; return serverOffsetUtc(region, game) * HOUR - RESET_HOUR_LOCAL * HOUR;
} }
@@ -155,18 +160,18 @@ function shift(region: Region, game?: GameId): number {
* generic "what day is it here?" — still gets the regional answer. Anything * generic "what day is it here?" — still gets the regional answer. Anything
* that reads or writes a tick should pass it. * that reads or writes a tick should pass it.
*/ */
export function dayKey(ms: number, region: Region, game?: GameId): string { export function dayKey(ms: number, region: Region, game?: LaneId): string {
return new Date(ms + shift(region, game)).toISOString().slice(0, 10); return new Date(ms + shift(region, game)).toISOString().slice(0, 10);
} }
/** The next reset instant strictly after `ms`. */ /** The next reset instant strictly after `ms`. */
export function nextResetMs(ms: number, region: Region, game?: GameId): number { export function nextResetMs(ms: number, region: Region, game?: LaneId): number {
const s = shift(region, game); const s = shift(region, game);
return Math.floor((ms + s) / DAY) * DAY + DAY - s; return Math.floor((ms + s) / DAY) * DAY + DAY - s;
} }
/** How long the reader has left to do today's dailies. */ /** How long the reader has left to do today's dailies. */
export function msUntilReset(ms: number, region: Region, game?: GameId): number { export function msUntilReset(ms: number, region: Region, game?: LaneId): number {
return nextResetMs(ms, region, game) - ms; return nextResetMs(ms, region, game) - ms;
} }
@@ -181,7 +186,7 @@ export function dailyDays(
startsMs: number, startsMs: number,
endsMs: number | null, endsMs: number | null,
region: Region, region: Region,
game?: GameId, game?: LaneId,
): string[] | null { ): string[] | null {
if (endsMs === null) return null; if (endsMs === null) return null;
@@ -230,7 +235,7 @@ export function dailySummary(input: {
endsMs: number | null; endsMs: number | null;
region: Region; region: Region;
/** Whose reset clock this runs on. Omitted falls back to the region's. */ /** Whose reset clock this runs on. Omitted falls back to the region's. */
game?: GameId | undefined; game?: LaneId | undefined;
now: number; now: number;
logged: readonly string[]; logged: readonly string[];
}): DailySummary { }): DailySummary {
+46
View File
@@ -1,3 +1,4 @@
import type { CustomGames, LaneId } from "./custom.ts";
import type { GameId, Region } from "./schema.ts"; import type { GameId, Region } from "./schema.ts";
export interface GameMeta { export interface GameMeta {
@@ -72,3 +73,48 @@ export const GAME_LIST: GameMeta[] = Object.values(GAMES);
export function gameMeta(id: GameId): GameMeta { export function gameMeta(id: GameId): GameMeta {
return GAMES[id]; return GAMES[id];
} }
/**
* Meta for any lane, including one the reader invented (PRD F13).
*
* Pure, and total. Total matters: a lane id can outlive the game it names —
* an import can carry an event whose game did not come with it, and a reader
* can delete a game a stale render is still holding. Returning a neutral
* placeholder keeps that a visible oddity rather than a blank screen, which is
* the trade this codebase makes everywhere else in the client.
*/
export function metaFor(id: LaneId, custom: CustomGames): GameMeta {
const tracked = GAMES[id as GameId];
if (tracked !== undefined) return tracked;
const own = custom[id];
if (own !== undefined) {
return {
id: own.id as GameId,
name: own.name,
short: shortLabel(own.name),
hue: own.hue,
// Not credited in the colophon and contributing no standing chore: the
// colophon lists the sources we fetch, and this game has none. See
// docs/DATA-MODEL.md § Reader-authored key spaces.
studio: "",
dailyTasks: "",
};
}
return {
id: id as GameId,
name: "Unknown game",
short: "?",
hue: "#9AA3B8",
studio: "",
dailyTasks: "",
};
}
/** A name that still fits a narrow lane label or a chip. */
export function shortLabel(name: string): string {
if (name.length <= 12) return name;
const first = name.split(/\s+/)[0] ?? name;
return first.length <= 12 ? first : `${name.slice(0, 11)}`;
}
+19 -3
View File
@@ -1,4 +1,5 @@
import type { GachaEvent, Region } from "./schema.ts"; import type { DisplayEvent } from "./custom.ts";
import type { Region } from "./schema.ts";
/** /**
* Time is this product's entire subject, so the vocabulary lives in one place: * Time is this product's entire subject, so the vocabulary lives in one place:
@@ -28,8 +29,23 @@ export function guessRegion(
return "europe"; return "europe";
} }
/**
* The boundary fields these helpers read.
*
* Structural rather than `GachaEvent` so a reader's own event (PRD F13) runs on
* exactly the same clock as a scraped one — there is no second countdown
* implementation to keep honest.
*/
export type EndBearing = Pick<
DisplayEvent,
"endsAt" | "regionScoped" | "regionEnds"
>;
/** The end instant to show this user, honouring a region-scoped event. */ /** The end instant to show this user, honouring a region-scoped event. */
export function effectiveEnd(event: GachaEvent, region: Region): string | null { export function effectiveEnd(
event: EndBearing,
region: Region,
): string | null {
if (event.endsAt === null) return null; if (event.endsAt === null) return null;
if (!event.regionScoped || event.regionEnds === null) return event.endsAt; if (!event.regionScoped || event.regionEnds === null) return event.endsAt;
return event.regionEnds[region] ?? event.endsAt; return event.regionEnds[region] ?? event.endsAt;
@@ -96,7 +112,7 @@ export interface EventClock {
} }
export function clockFor( export function clockFor(
event: GachaEvent, event: EndBearing & Pick<DisplayEvent, "startsAt">,
region: Region, region: Region,
now: number, now: number,
): EventClock { ): EventClock {
+246
View File
@@ -0,0 +1,246 @@
import { describe, expect, test } from "bun:test";
import {
asDisplayEvent,
CustomEvent,
CustomGame,
isCustomEventId,
isCustomGameId,
knownLane,
mintCustomEventId,
mintCustomGameId,
precisionOf,
RESERVED_ID_SEGMENTS,
type CustomGames,
} from "../src/shared/custom.ts";
import { metaFor } from "../src/shared/games.ts";
import { dailiesId } from "../src/shared/daily.ts";
import { eventId, GameId } from "../src/shared/schema.ts";
import { clockFor } from "../src/shared/time.ts";
const AT = "2026-08-17T12:00:00.000Z";
function ownEvent(over: Partial<CustomEvent> = {}): CustomEvent {
return CustomEvent.parse({
id: "myevent:k3f9qa2m01",
game: "mygame:limbus-company",
title: "Walpurgisnacht",
type: "banner",
summary: null,
startsAt: "2026-08-20T00:00:00.000Z",
startPrecision: "day",
endsAt: "2026-09-03T00:00:00.000Z",
endPrecision: "day",
at: AT,
updatedAt: AT,
...over,
});
}
describe("reserved id segments", () => {
test("no game id can ever occupy a reserved first segment", () => {
// Every id in the app is colon-separated and the first segment decides
// which key space it belongs to. The day a GameId is called "mygame" is the
// day two spaces merge silently, and localStorage has no other copy.
for (const reserved of RESERVED_ID_SEGMENTS) {
expect(GameId.options as readonly string[]).not.toContain(reserved);
}
});
test("the three spaces cannot produce the same key", () => {
const feed = eventId("genshin", "Walpurgisnacht", "2026-08-20T00:00:00.000Z");
const chore = dailiesId("genshin");
const own = mintCustomEventId(() => 0.5);
const ownGame = mintCustomGameId("Limbus Company");
const keys = [feed, chore, own, ownGame];
expect(new Set(keys).size).toBe(keys.length);
for (const key of keys) {
expect(key.split(":").length).toBeGreaterThanOrEqual(2);
}
});
test("a reader's event never collides with the scraped event it names", () => {
// The whole reason for a random suffix: they can type a tracked event's
// exact title and date. Under the feed's scheme that is byte-identical.
const scraped = eventId("genshin", "Windblume Festival", "2026-03-14T00:00:00.000Z");
const mine = mintCustomEventId(() => 0.123456);
expect(mine).not.toBe(scraped);
expect(isCustomEventId(mine)).toBe(true);
expect(isCustomEventId(scraped)).toBe(false);
});
});
describe("minting ids", () => {
test("a game id is slug-derived and disambiguated rather than overwritten", () => {
expect(mintCustomGameId("Limbus Company")).toBe("mygame:limbus-company");
// Two games called Nikke are two games.
expect(mintCustomGameId("Nikke", ["mygame:nikke"])).toBe("mygame:nikke-2");
expect(mintCustomGameId("Nikke", ["mygame:nikke", "mygame:nikke-2"])).toBe(
"mygame:nikke-3",
);
});
test("a game whose name slugifies to nothing still gets an id", () => {
expect(mintCustomGameId("???")).toBe("mygame:game");
});
test("event ids match their schema and vary with the source of randomness", () => {
const a = mintCustomEventId(() => 0.1);
const b = mintCustomEventId(() => 0.9);
expect(a).toMatch(/^myevent:[a-z0-9]{10}$/);
expect(b).toMatch(/^myevent:[a-z0-9]{10}$/);
expect(a).not.toBe(b);
expect(isCustomGameId(a)).toBe(false);
});
});
describe("CustomEvent", () => {
test("an unannounced end is expressible, and must pair with unknown", () => {
// A reader entering an event nobody has dated must not be forced to invent
// one — that is the failure this whole product is built against.
const open = ownEvent({ endsAt: null, endPrecision: "unknown" });
expect(open.endsAt).toBeNull();
expect(() =>
CustomEvent.parse({ ...ownEvent(), endsAt: null, endPrecision: "day" }),
).toThrow();
expect(() =>
CustomEvent.parse({
...ownEvent(),
endsAt: "2026-09-03T00:00:00.000Z",
endPrecision: "unknown",
}),
).toThrow();
});
test("rejects an end before its start", () => {
expect(() =>
CustomEvent.parse({ ...ownEvent(), endsAt: "2026-08-19T00:00:00.000Z" }),
).toThrow();
});
test("rejects an empty or oversized title", () => {
expect(() => CustomEvent.parse({ ...ownEvent(), title: "" })).toThrow();
expect(() =>
CustomEvent.parse({ ...ownEvent(), title: "x".repeat(201) }),
).toThrow();
});
});
describe("CustomGame", () => {
test("a hue must be a hex colour, because it reaches a style attribute", () => {
// An imported file is not necessarily one this reader wrote.
const ok = CustomGame.parse({
id: "mygame:limbus-company",
name: "Limbus Company",
hue: "#C74B50",
at: AT,
});
expect(ok.hue).toBe("#C74B50");
for (const hue of ["red", "url(javascript:alert(1))", "#fff", "#12345g", ""]) {
expect(() =>
CustomGame.parse({ id: "mygame:x", name: "X", hue, at: AT }),
).toThrow();
}
});
test("rejects an id from another key space", () => {
expect(() =>
CustomGame.parse({ id: "genshin", name: "Genshin", hue: "#4EA8DE", at: AT }),
).toThrow();
});
});
describe("asDisplayEvent", () => {
test("carries no source and claims no region split", () => {
const shown = asDisplayEvent(ownEvent());
// Never attributed to a source: there is no page to send a sceptic to.
expect(shown.sourceUrl).toBeNull();
// One instant was entered, so inventing three would fabricate two of them.
expect(shown.regionScoped).toBe(false);
expect(shown.regionEnds).toBeNull();
expect(shown.extractionMethod).toBe("manual");
expect(shown.status).toBe("published");
});
test("runs on the same clock as a scraped event", () => {
// Not a second countdown implementation — the identical one.
const clock = clockFor(
asDisplayEvent(ownEvent()),
"europe",
Date.parse("2026-08-27T00:00:00.000Z"),
);
expect(clock.live).toBe(true);
expect(clock.msRemaining).toBe(7 * 24 * 60 * 60 * 1000);
});
test("an unannounced end yields no countdown, exactly as the feed's does", () => {
const clock = clockFor(
asDisplayEvent(ownEvent({ endsAt: null, endPrecision: "unknown" })),
"europe",
Date.parse("2026-08-27T00:00:00.000Z"),
);
expect(clock.msRemaining).toBeNull();
expect(clock.urgency).toBe("calm");
});
});
describe("precisionOf", () => {
test("a date with no time of day is day precision", () => {
// So the detail sheet's "accurate to the day only" note is honest about
// the reader's input too, rather than presenting midnight as their choice.
expect(precisionOf(false)).toBe("day");
expect(precisionOf(true)).toBe("exact");
});
});
describe("metaFor", () => {
const mine: CustomGames = {
"mygame:limbus-company": {
id: "mygame:limbus-company",
name: "Limbus Company",
hue: "#C74B50",
at: AT,
},
};
test("answers for a tracked game unchanged", () => {
expect(metaFor("genshin", mine).name).toBe("Genshin Impact");
expect(metaFor("genshin", mine).studio).toBe("HoYoverse");
});
test("answers for one the reader defined, with no studio or chore", () => {
const meta = metaFor("mygame:limbus-company", mine);
expect(meta.name).toBe("Limbus Company");
expect(meta.hue).toBe("#C74B50");
// Nothing to credit in the colophon and no routine we could name for them.
expect(meta.studio).toBe("");
expect(meta.dailyTasks).toBe("");
});
test("is total, so a lane that outlived its game cannot blank the page", () => {
// An import can carry an event whose game did not come with it.
const meta = metaFor("mygame:deleted", mine);
expect(meta.name).toBe("Unknown game");
expect(meta.hue).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
test("shortens a long name rather than overflowing a chip", () => {
const long: CustomGames = {
"mygame:x": { id: "mygame:x", name: "Chaos Zero Nightmare", hue: "#123456", at: AT },
};
expect(metaFor("mygame:x", long).short.length).toBeLessThanOrEqual(12);
});
});
describe("knownLane", () => {
test("a tracked lane is always known; a reader's lane must still exist", () => {
const mine: CustomGames = {
"mygame:a": { id: "mygame:a", name: "A", hue: "#123456", at: AT },
};
expect(knownLane("genshin", mine)).toBe(true);
expect(knownLane("mygame:a", mine)).toBe(true);
expect(knownLane("mygame:gone", mine)).toBe(false);
});
});