diff --git a/src/client/App.tsx b/src/client/App.tsx index f2f51c6..dba30a5 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -26,8 +26,8 @@ import { } from "./state/lens.ts"; import { clockFor, DAY, formatRemaining } from "../shared/time.ts"; import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; -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"; 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 // a row that just disappeared. const [lastIgnored, setLastIgnored] = useState<{ id: string; title: string } | null>(null); + const gameMeta = useGameMeta(); const now = useNow(); const online = useOnline(); const { prefs, update, toggleGame } = usePrefs(); @@ -151,7 +152,7 @@ export function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [state, prefs.region, Math.floor(now / 60_000)]); - const games = useMemo( + const games = useMemo( () => [...new Set(allRows.map((r) => r.event.game))], [allRows], ); diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx index 6cb2220..29a81bd 100644 --- a/src/client/components/Controls.tsx +++ b/src/client/components/Controls.tsx @@ -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) => void; ignoredCount: number; onExport: () => void; onImport: (file: File) => void; }) { + const gameMeta = useGameMeta(); return (

Games

diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx index 832858d..b191a95 100644 --- a/src/client/components/Dailies.tsx +++ b/src/client/components/Dailies.tsx @@ -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), diff --git a/src/client/components/DailyChecklist.tsx b/src/client/components/DailyChecklist.tsx index 9406b7a..81a11d9 100644 --- a/src/client/components/DailyChecklist.tsx +++ b/src/client/components/DailyChecklist.tsx @@ -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; diff --git a/src/client/components/EventDetail.tsx b/src/client/components/EventDetail.tsx index 8e2dd18..0471279 100644 --- a/src/client/components/EventDetail.tsx +++ b/src/client/components/EventDetail.tsx @@ -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"} - - Source - + {/* 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 && ( + + Source + + )} {/* Ignoring is not completing. "Done" keeps an event visible and diff --git a/src/client/components/EventRow.tsx b/src/client/components/EventRow.tsx index 55a3f21..649c707 100644 --- a/src/client/components/EventRow.tsx +++ b/src/client/components/EventRow.tsx @@ -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]; diff --git a/src/client/components/GameFocus.tsx b/src/client/components/GameFocus.tsx index cba2eb9..355e65e 100644 --- a/src/client/components/GameFocus.tsx +++ b/src/client/components/GameFocus.tsx @@ -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>; + counts: Partial>; 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; diff --git a/src/client/components/NextUp.tsx b/src/client/components/NextUp.tsx index 78f4131..b1ed5fe 100644 --- a/src/client/components/NextUp.tsx +++ b/src/client/components/NextUp.tsx @@ -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 (
diff --git a/src/client/components/Timeline.tsx b/src/client/components/Timeline.tsx index 770dd0f..d4a42f8 100644 --- a/src/client/components/Timeline.tsx +++ b/src/client/components/Timeline.tsx @@ -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(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(); + const byGame = new Map(); for (const row of rows) { byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]); } diff --git a/src/client/components/Welcome.tsx b/src/client/components/Welcome.tsx index 7b9a17d..fbbe3b1 100644 --- a/src/client/components/Welcome.tsx +++ b/src/client/components/Welcome.tsx @@ -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([]); + const gameMeta = useGameMeta(); + const [chosen, setChosen] = useState([]); - const toggle = (id: GameId) => + const toggle = (id: LaneId) => setChosen((prev) => prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id], ); diff --git a/src/client/state/gameMeta.tsx b/src/client/state/gameMeta.tsx new file mode 100644 index 0000000..b877121 --- /dev/null +++ b/src/client/state/gameMeta.tsx @@ -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((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); +} diff --git a/src/client/state/lens.ts b/src/client/state/lens.ts index 47c19d7..85d5f81 100644 --- a/src/client/state/lens.ts +++ b/src/client/state/lens.ts @@ -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(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( rows: readonly T[], -): Partial> { - const out: Partial> = {}; +): Partial> { + const out: Partial> = {}; for (const row of rows) { out[row.event.game] = (out[row.event.game] ?? 0) + 1; } diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index 00c468b..b184653 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -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) diff --git a/src/shared/custom.ts b/src/shared/custom.ts new file mode 100644 index 0000000..d5698be --- /dev/null +++ b/src/shared/custom.ts @@ -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:` is a standing chore, `mygame:` and + * `myevent:` are the reader's own, and anything else is `::` + * 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; + +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; + +export const CustomGames = z.record(z.string(), CustomGame); +export const CustomEvents = z.record(z.string(), CustomEvent); +export type CustomGames = z.infer; +export type CustomEvents = z.infer; + +/** + * 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 & { + 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 { + 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 { + 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; +} diff --git a/src/shared/daily.ts b/src/shared/daily.ts index 8f8bf06..c352abc 100644 --- a/src/shared/daily.ts +++ b/src/shared/daily.ts @@ -1,4 +1,5 @@ import { GAMES } from "./games.ts"; +import type { LaneId } from "./custom.ts"; import type { GachaEvent, GameId, Region } from "./schema.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 * different bug in the same place. */ -export function serverOffsetUtc(region: Region, game?: GameId): number { - const override = game === undefined ? undefined : GAMES[game].resetOffsets?.[region]; +export function serverOffsetUtc(region: Region, game?: LaneId): number { + // 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]; } @@ -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 * 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; } @@ -155,18 +160,18 @@ function shift(region: Region, game?: GameId): number { * 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?: GameId): string { +export function dayKey(ms: number, region: Region, game?: LaneId): string { return new Date(ms + shift(region, game)).toISOString().slice(0, 10); } /** 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); 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?: GameId): number { +export function msUntilReset(ms: number, region: Region, game?: LaneId): number { return nextResetMs(ms, region, game) - ms; } @@ -181,7 +186,7 @@ export function dailyDays( startsMs: number, endsMs: number | null, region: Region, - game?: GameId, + game?: LaneId, ): string[] | null { if (endsMs === null) return null; @@ -230,7 +235,7 @@ export function dailySummary(input: { endsMs: number | null; region: Region; /** Whose reset clock this runs on. Omitted falls back to the region's. */ - game?: GameId | undefined; + game?: LaneId | undefined; now: number; logged: readonly string[]; }): DailySummary { diff --git a/src/shared/games.ts b/src/shared/games.ts index 3b80661..33e9384 100644 --- a/src/shared/games.ts +++ b/src/shared/games.ts @@ -1,3 +1,4 @@ +import type { CustomGames, LaneId } from "./custom.ts"; import type { GameId, Region } from "./schema.ts"; export interface GameMeta { @@ -72,3 +73,48 @@ export const GAME_LIST: GameMeta[] = Object.values(GAMES); export function gameMeta(id: GameId): GameMeta { 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)}…`; +} diff --git a/src/shared/time.ts b/src/shared/time.ts index 8cc338d..e9e14a9 100644 --- a/src/shared/time.ts +++ b/src/shared/time.ts @@ -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: @@ -28,8 +29,23 @@ export function guessRegion( 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. */ -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.regionScoped || event.regionEnds === null) return event.endsAt; return event.regionEnds[region] ?? event.endsAt; @@ -96,7 +112,7 @@ export interface EventClock { } export function clockFor( - event: GachaEvent, + event: EndBearing & Pick, region: Region, now: number, ): EventClock { diff --git a/test/custom.test.ts b/test/custom.test.ts new file mode 100644 index 0000000..a69b560 --- /dev/null +++ b/test/custom.test.ts @@ -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 { + 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); + }); +});