diff --git a/README.md b/README.md index 7ac6488..d16c912 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,11 @@ carry no source link because there is no page to send a sceptic to, and they travel in your export. Like everything else here, your browser holds the only copy. +Settings lists everything you have added, whatever state it is in, and opens +the same panel a row does. Every other surface drops an event once it has +ended, so that list is the only way back to a one-off of yours after its end +date passes — to edit it, or to get rid of it. + ### How often it comes round An event you add states its **cadence** — asked before any date, because the diff --git a/docs/PRD.md b/docs/PRD.md index 837cc61..71017e8 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -317,6 +317,11 @@ Four constraints, each protecting something that already exists: - **Their events never touch the ingest pipeline.** `sanitize.ts` and `merge.ts` exist for pages we do not control; a reader's own typing is neither untrusted markup nor a second opinion to reconcile. Nothing they enter is fetched, parsed, merged, scored or quarantined. +- **They are always reachable.** Settings lists every event the reader has made, whatever state it + is in, and opens the same detail sheet a row does. Every other surface drops an event once it has + ended, so without this list a one-off of their own became unreachable the day it finished — + impossible to edit, and impossible to delete out of a store nothing else can see. Events filed + under a game we track are listed too; the form allows that, so the index has to. - **Their IDs live in their own key space.** Never `${game}:${slug}:${date}` — see `docs/DATA-MODEL.md` § Reader-authored key spaces. - **They are in the backup.** An export that omitted hand-entered events would be a lossy backup, diff --git a/src/client/App.tsx b/src/client/App.tsx index f13e96b..2ff9fea 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -30,7 +30,7 @@ import { } from "./state/lens.ts"; import { clockFor, formatRemaining } from "../shared/time.ts"; import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; -import { occurrenceForId, strandedOccurrences } from "../shared/recurrence.ts"; +import { nearestOccurrence, occurrenceForId, strandedOccurrences } from "../shared/recurrence.ts"; import { orderGames } from "./state/gameOrder.ts"; import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx"; import { metaOnTheme, useTheme } from "./state/theme.ts"; @@ -650,6 +650,16 @@ export function App() { onEditGame: custom.editGame, onRemoveGame: custom.removeGame, onAddEvent: custom.addEvent, + now, + // The index lists rules; the sheet opens rows. A repeating rule's + // own id is never a row — the lists hold its occurrences — so it is + // resolved to whichever occurrence is nearest before opening. + onOpen: (id) => { + const record = custom.events[id]; + const occurrence = + record === undefined ? null : nearestOccurrence(record, now); + setOpenId(occurrence === null ? id : occurrence.id); + }, }} onExport={() => exportProgress(prog.progress, daily.logs, ignored.marks, prefs, { diff --git a/src/client/components/YourOwn.tsx b/src/client/components/YourOwn.tsx index d60fafe..d6016d6 100644 --- a/src/client/components/YourOwn.tsx +++ b/src/client/components/YourOwn.tsx @@ -1,16 +1,47 @@ import { useState } from "react"; -import type { CustomEvents, CustomGames, LaneId } from "../../shared/custom.ts"; +import type { CustomEvent, CustomEvents, CustomGames, LaneId } from "../../shared/custom.ts"; +import { formatAbsolute } from "../../shared/time.ts"; import type { EventDraft } from "../state/useCustom.ts"; -import { EventForm, GameForm } from "./CustomForms.tsx"; +import { useGameMeta } from "../state/gameMeta.tsx"; +import { cadenceLabel, EventForm, GameForm } from "./CustomForms.tsx"; /** * The reader's own games and events — a group of the settings panel (PRD F13). * - * Their events are managed from the event itself — open it and the detail sheet - * offers edit and delete, exactly where you would look for them. What has no - * other home is the list of games they invented, and the way in to adding the - * first event, so both live here. + * Their events are still *managed* from the event itself — open one and the + * detail sheet offers edit and delete, exactly where you would look for them. + * What lives here is the way back **to** it, which the rest of the app cannot + * offer: every list and the board drop an event once it has ended, so a + * one-off of the reader's own became unreachable the day it finished — + * impossible to edit, and impossible to delete out of a store nothing else can + * reach. This index is the only surface that shows an event whatever state it + * is in. */ + +/** + * What a row says about itself, beyond its title. + * + * The job is to explain why an event is not on any other surface, because a + * list of bare titles leaves the reader guessing which of two entries is the + * dead one. A repeating event says how often instead of when: its dates roll + * forward, so printing one would disagree with the row they would find if they + * went looking. + */ +export function eventCaption(event: CustomEvent, nowMs: number): string { + const cadence = cadenceLabel(event.repeat); + if (cadence !== null) return cadence; + + if (event.endsAt !== null && Date.parse(event.endsAt) < nowMs) { + return `ended ${formatAbsolute(Date.parse(event.endsAt), false)}`; + } + if (Date.parse(event.startsAt) > nowMs) { + return `starts ${formatAbsolute(Date.parse(event.startsAt), false)}`; + } + return event.endsAt === null + ? "no end date" + : `until ${formatAbsolute(Date.parse(event.endsAt), false)}`; +} + export function YourOwn({ games, events, @@ -19,6 +50,8 @@ export function YourOwn({ onEditGame, onRemoveGame, onAddEvent, + now, + onOpen, }: { games: CustomGames; events: CustomEvents; @@ -28,12 +61,27 @@ export function YourOwn({ onEditGame: (id: string, name: string, hue: string) => void; onRemoveGame: (id: string) => { removed: boolean; blockedBy: number }; onAddEvent: (draft: EventDraft) => void; + now: number; + /** + * Open one of their events. Takes the stored id — a rule's, not an + * occurrence's — and the caller resolves it to whichever row the sheet can + * actually show. + */ + onOpen: (id: string) => void; }) { + const gameMeta = useGameMeta(); const [adding, setAdding] = useState<"game" | "event" | null>(null); const [editing, setEditing] = useState(null); const [refusal, setRefusal] = useState(null); const list = Object.values(games); + // An event may be filed under a game we track — a source can miss one — and + // those have no row above to nest under. Listing them separately is what + // keeps this index complete: an ended event under Genshin is on no other + // surface either, and would be just as stuck. + const underTracked = Object.values(events).filter( + (e) => games[e.game] === undefined, + ); return ( // No heading or rule of its own: this is the body of a settings group that @@ -48,9 +96,8 @@ export function YourOwn({ {list.length > 0 && (