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:
co-authored by
Claude Opus 5
parent
4029885833
commit
3702ee7a4c
+4
-3
@@ -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<GameId[]>(
|
||||
const games = useMemo<LaneId[]>(
|
||||
() => [...new Set(allRows.map((r) => r.event.game))],
|
||||
[allRows],
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user