Make a stored id always name a row

Review found the new settings row opening nothing for a rule whose `until`
precedes its `startsAt`. That parses — nothing ties the two together — and
yields no occurrence at all, so nextOccurrences and the anchor fallback both
come back empty, nearestOccurrence returns null, and openRow cannot resolve
a bare rule id. It was the undeletable record this index exists to rescue,
now with a button that lies about it.

So resolution moves out of App into displayEventFor, with the rule itself as
the floor: a stored id always names something the reader can edit and
delete, whatever the rule does or does not generate. Exported because
nothing here can click and no test renders App, which is exactly how a dead
button shipped — the chain is now covered without a DOM.

Two of the caption tests could not fail, proven by mutation rather than
read: deleting the whole "starts" branch and removing the null-end guard
both left the suite green, because `not.toContain("ended")` was never
watching the branch at risk. They assert what the caption says now.

And a repeating series that has stopped said only how often it repeats — in
the one place whose job is explaining why an event is on no other surface. A
healthy cadence explains nothing; it says when it stopped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-28 05:25:37 +02:00
co-authored by Claude Opus 5
parent 3a3b7d9c2f
commit 86ba2ccc1d
6 changed files with 157 additions and 28 deletions
+27 -9
View File
@@ -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");
});
});
+58
View File
@@ -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);
});
});