diff --git a/docs/PRD.md b/docs/PRD.md
index 71017e8..60faa1b 100644
--- a/docs/PRD.md
+++ b/docs/PRD.md
@@ -317,8 +317,10 @@ 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
+- **They are reachable whenever the app is.** Settings lists every event the reader has made,
+ whatever state it is in, and opens the same detail sheet a row does. "Whenever the app is" is
+ literal: settings renders on the ready path, so a feed that fails to load takes the whole panel
+ with it — a pre-existing limit worth naming rather than implying otherwise. 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.
diff --git a/src/client/App.tsx b/src/client/App.tsx
index 2ff9fea..354476a 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -30,12 +30,12 @@ import {
} from "./state/lens.ts";
import { clockFor, formatRemaining } from "../shared/time.ts";
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
-import { nearestOccurrence, occurrenceForId, strandedOccurrences } from "../shared/recurrence.ts";
+import { 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";
import {
- asOccurrenceEvent,
+ displayEventFor,
recordFor,
type CustomEvents,
type CustomGames,
@@ -369,11 +369,8 @@ export function App() {
const openRow = (() => {
const hit = allRows.find((r) => r.event.id === openId) ?? null;
if (hit !== null || openId === null) return hit;
- const record = recordFor(custom.events, openId);
- if (record === undefined) return null;
- const occurrence = occurrenceForId(record, openId);
- if (occurrence === null) return null;
- const event = asOccurrenceEvent(record, occurrence);
+ const event = displayEventFor(custom.events, openId, now);
+ if (event === null) return null;
return { event, clock: clockFor(event, prefs.region, now) };
})();
@@ -654,12 +651,10 @@ export function App() {
// 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);
- },
+ // The stored id is enough: `displayEventFor` resolves a rule to
+ // whichever occurrence the sheet can show, and to the rule itself
+ // when it has none.
+ onOpen: setOpenId,
}}
onExport={() =>
exportProgress(prog.progress, daily.logs, ignored.marks, prefs, {
diff --git a/src/client/components/YourOwn.tsx b/src/client/components/YourOwn.tsx
index d6016d6..25470de 100644
--- a/src/client/components/YourOwn.tsx
+++ b/src/client/components/YourOwn.tsx
@@ -29,7 +29,16 @@ import { cadenceLabel, EventForm, GameForm } from "./CustomForms.tsx";
*/
export function eventCaption(event: CustomEvent, nowMs: number): string {
const cadence = cadenceLabel(event.repeat);
- if (cadence !== null) return cadence;
+ if (cadence !== null) {
+ // A series that has stopped is exactly what this list exists to explain,
+ // and a healthy-looking cadence explains nothing: the reader would see
+ // "on a weekly cycle" and no reason it is missing from the board.
+ const until = event.repeat?.until ?? null;
+ if (until !== null && Date.parse(until) < nowMs) {
+ return `stopped ${formatAbsolute(Date.parse(until), false)}`;
+ }
+ return cadence;
+ }
if (event.endsAt !== null && Date.parse(event.endsAt) < nowMs) {
return `ended ${formatAbsolute(Date.parse(event.endsAt), false)}`;
@@ -78,7 +87,10 @@ export function YourOwn({
// 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.
+ // surface either, and would be just as stuck. It also catches an event whose
+ // own lane has since gone, which `removeGame` refuses to cause but an import
+ // can still deliver — hence "another game" rather than "a game we track",
+ // which would be false for exactly that row.
const underTracked = Object.values(events).filter(
(e) => games[e.game] === undefined,
);
@@ -186,7 +198,7 @@ export function YourOwn({
{/* Its own heading rather than trailing the list above, which read as
though these belonged to whichever game happened to be last. */}
{underTracked.length > 0 && (
-
Filed under a game we track
+ Filed under another game
)}
{underTracked.length > 0 && (
diff --git a/src/shared/custom.ts b/src/shared/custom.ts
index 2c1918d..90fce2d 100644
--- a/src/shared/custom.ts
+++ b/src/shared/custom.ts
@@ -1,5 +1,12 @@
import { z } from "zod";
-import { comesRoundEarly, Repeat, ruleIdOf } from "./recurrence.ts";
+import {
+ comesRoundEarly,
+ isOccurrenceId,
+ nearestOccurrence,
+ occurrenceForId,
+ Repeat,
+ ruleIdOf,
+} from "./recurrence.ts";
import type { Occurrence } from "./recurrence.ts";
import { EventType, GachaEvent, Precision, slugify } from "./schema.ts";
@@ -289,3 +296,40 @@ export function recordFor(
): CustomEvent | undefined {
return events[ruleIdOf(rowId)];
}
+
+/**
+ * The row an id names, for anything the reader entered themselves.
+ *
+ * The detail sheet opens rows, and an id reaches it from three places that do
+ * not agree about shape: a list row carries an occurrence id, the settings
+ * index carries a stored rule id, and a plain event's id is both at once.
+ * This is the one place that reconciles them, and it is exported rather than
+ * inlined because nothing in this project can click: no test renders the app,
+ * so an id the sheet cannot resolve was invisible to the whole suite until it
+ * had already shipped as a button that does nothing.
+ *
+ * **The last branch is a floor, not a nicety.** A rule can legitimately
+ * produce no occurrence at all — `until` earlier than `startsAt` parses fine
+ * and is reachable by import — which puts it on no list, no board and no
+ * timeline. Returning null for it would leave the settings index opening
+ * nothing for exactly the record that index exists to rescue. Falling back to
+ * the rule itself means a stored id always names something the reader can
+ * edit and delete.
+ */
+export function displayEventFor(
+ events: CustomEvents,
+ rowId: string,
+ nowMs: number,
+): DisplayEvent | null {
+ const record = recordFor(events, rowId);
+ if (record === undefined) return null;
+
+ if (record.repeat === null) return asDisplayEvent(record);
+
+ const occurrence = isOccurrenceId(rowId)
+ ? occurrenceForId(record, rowId)
+ : nearestOccurrence(record, nowMs);
+ return occurrence === null
+ ? asDisplayEvent(record)
+ : asOccurrenceEvent(record, occurrence);
+}
diff --git a/test/custom-ui.test.tsx b/test/custom-ui.test.tsx
index c43470e..d581835 100644
--- a/test/custom-ui.test.tsx
+++ b/test/custom-ui.test.tsx
@@ -921,20 +921,38 @@ describe("eventCaption", () => {
expect(eventCaption(at({}), NOW)).toContain("ended");
});
- test("an event still to come is not reported as ended", () => {
- const caption = eventCaption(
- at({ startsAt: "2026-11-01T00:00:00.000Z", endsAt: "2026-11-08T00:00:00.000Z" }),
- NOW,
- );
- expect(caption).not.toContain("ended");
+ test("an event still to come says when it starts", () => {
+ // Asserted on what it says, not on what it avoids saying: `not.toContain`
+ // passed even with this whole branch deleted, because the ended branch was
+ // never the thing at risk.
+ expect(
+ eventCaption(
+ at({ startsAt: "2026-11-01T00:00:00.000Z", endsAt: "2026-11-08T00:00:00.000Z" }),
+ NOW,
+ ),
+ ).toContain("starts");
});
- test("an unannounced end is not mistaken for a passed one", () => {
+ test("an unannounced end says so rather than reading as passed", () => {
+ // Date.parse(null) is NaN and NaN < now is false, so the null guard was
+ // unobservable through a `not.toContain("ended")` assertion.
+ expect(eventCaption(at({ endsAt: null, endPrecision: "unknown" }), NOW)).toBe(
+ "no end date",
+ );
+ });
+
+ test("a repeating series that has stopped says so, not just how often", () => {
+ // This list's whole job is explaining why an event is on no other surface.
+ // A finished series that reports a healthy cadence explains nothing.
const caption = eventCaption(
- at({ endsAt: null, endPrecision: "unknown" }),
+ at({
+ endsAt: null,
+ endPrecision: "unknown",
+ repeat: { unit: "weeks", interval: 1, until: "2026-09-20T00:00:00.000Z" },
+ }),
NOW,
);
- expect(caption).not.toContain("ended");
+ expect(caption).toContain("stopped");
});
});
diff --git a/test/custom.test.ts b/test/custom.test.ts
index cb3e2e1..274c282 100644
--- a/test/custom.test.ts
+++ b/test/custom.test.ts
@@ -10,6 +10,7 @@ import {
mintCustomEventId,
mintCustomGameId,
precisionOf,
+ displayEventFor,
recordFor,
RESERVED_ID_SEGMENTS,
type CustomGames,
@@ -640,3 +641,60 @@ describe("recordFor", () => {
expect(recordFor({}, "myevent:k3f9qa2m01#2026-09-15")).toBeUndefined();
});
});
+
+describe("displayEventFor", () => {
+ // The resolution chain the detail sheet depends on, pulled out of App so it
+ // can be tested without a DOM. Nothing in this project can click, and no
+ // test renders App at all, so a row that sets an id the sheet cannot resolve
+ // was invisible to the whole suite — which is exactly how a settings row
+ // that opens nothing got shipped.
+ const plain = ownEvent({ id: "myevent:plain00001", repeat: null });
+ const repeating = ownEvent({
+ id: "myevent:k3f9qa2m01",
+ startsAt: new Date("2026-09-01T09:00:00").toISOString(),
+ startPrecision: "exact",
+ endsAt: new Date("2026-09-08T09:00:00").toISOString(),
+ endPrecision: "exact",
+ repeat: { unit: "weeks", interval: 2, until: null },
+ });
+ // Reachable only by import: nothing in the form can set `until` at all, and
+ // `CustomEvent` does not tie it to `startsAt`. It yields no occurrences, so
+ // it is on no list, no board and no timeline.
+ const stillborn = ownEvent({
+ id: "myevent:stillborn1",
+ startsAt: new Date("2026-09-01T09:00:00").toISOString(),
+ startPrecision: "exact",
+ endsAt: null,
+ endPrecision: "unknown",
+ repeat: { unit: "weeks", interval: 2, until: "2020-01-01T00:00:00.000Z" },
+ });
+ const store = {
+ [plain.id]: plain,
+ [repeating.id]: repeating,
+ [stillborn.id]: stillborn,
+ };
+ const NOW = new Date("2026-09-03T12:00:00").getTime();
+
+ test("a plain event resolves to itself", () => {
+ expect(displayEventFor(store, "myevent:plain00001", NOW)?.id).toBe("myevent:plain00001");
+ });
+
+ test("an occurrence id resolves to that occurrence", () => {
+ const row = displayEventFor(store, "myevent:k3f9qa2m01#2026-09-15", NOW);
+ expect(row?.id).toBe("myevent:k3f9qa2m01#2026-09-15");
+ });
+
+ test("a rule id resolves even when the rule produces no occurrence", () => {
+ // The finding. Its series ended before it began, so `nearestOccurrence`
+ // has nothing to offer — and without a floor here the settings row that
+ // exists to rescue this record opens nothing at all, which is worse than
+ // no button.
+ const row = displayEventFor(store, "myevent:stillborn1", NOW);
+ expect(row?.id).toBe("myevent:stillborn1");
+ });
+
+ test("an id belonging to nobody resolves to nothing", () => {
+ expect(displayEventFor(store, "genshin:some-event:2026-09-01", NOW)).toBe(null);
+ expect(displayEventFor({}, "myevent:plain00001", NOW)).toBe(null);
+ });
+});