Give a reader their own events back

An event of theirs that had ended was on no surface at all. Every list and
the board drop a row once its end has passed, and settings only counted the
events a game held rather than naming them — so a one-off became unreachable
the day it finished: impossible to edit, and impossible to delete out of a
store nothing else can see. Repeating events escaped only because their
occurrences roll forward.

So settings names them now, under the game they were filed against, each row
opening the same detail sheet a row on the front page does. Nothing about
how they are managed changes; what was missing was the way back to them.

Two things the index has to get right or it leaves the same hole it closes.
The lists hold occurrences, never rules, so a repeating rule's own id opens
nothing — nearestOccurrence bridges that, and answers for a finished series
too by falling back to the first occurrence, since a rule whose `until` has
passed would otherwise be exactly as stuck. And an event filed under a game
we track has no row of theirs to nest under; the form allows that, so those
get their own heading rather than trailing the list and reading as though
they belonged to whichever game came last.

Each row says why it is not on the front page — ended and when, or its
cadence — because a list of bare titles leaves you guessing which of two
entries is the dead one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-28 05:14:47 +02:00
co-authored by Claude Opus 5
parent 8671a309b0
commit 3a3b7d9c2f
8 changed files with 335 additions and 12 deletions
+44 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { addUnits, comesRoundEarly, Repeat, repeatSpanning, repeatModeOf, cadenceOf, isOccurrenceId, occurrenceId, occurrenceForId, ruleIdOf, movesOccurrences, nextOccurrences, occurrencesOf, strandedOccurrences, type RepeatingEvent } from "../src/shared/recurrence.ts";
import { addUnits, comesRoundEarly, Repeat, repeatSpanning, repeatModeOf, cadenceOf, nearestOccurrence, isOccurrenceId, occurrenceId, occurrenceForId, ruleIdOf, movesOccurrences, nextOccurrences, occurrencesOf, strandedOccurrences, type RepeatingEvent } from "../src/shared/recurrence.ts";
import { CustomEventId, isCustomEventId } from "../src/shared/custom.ts";
// Pinned so the DST cases mean something. Copenhagen is UTC+1 in winter and
@@ -590,3 +590,46 @@ describe("cadenceOf", () => {
).toBe("custom");
});
});
describe("nearestOccurrence", () => {
// The settings index lists rules, but the detail sheet opens rows, and a
// rule's own id is never a row — `allRows` holds its occurrences. This is
// the bridge, and it has to answer for a dead series too: a rule whose
// `until` has passed has no future occurrence at all, and without an answer
// it would be exactly as unreachable as the ended one-off this fixes.
const rule = (over: Partial<RepeatingEvent> = {}): RepeatingEvent => ({
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 },
...over,
});
test("a running occurrence is the nearest one", () => {
const got = nearestOccurrence(rule(), new Date("2026-09-03T12:00:00").getTime());
expect(got?.id).toBe("myevent:k3f9qa2m01#2026-09-01");
});
test("between cycles it is the one about to open", () => {
const got = nearestOccurrence(rule(), new Date("2026-09-10T12:00:00").getTime());
expect(got?.id).toBe("myevent:k3f9qa2m01#2026-09-15");
});
test("a finished series still resolves, so it can still be reached", () => {
// Any occurrence will do here: the reader has come to edit or delete the
// rule, not to inspect a particular time round. The first always exists,
// which is what makes this total.
const dead = rule({
repeat: { unit: "weeks", interval: 2, until: new Date("2026-09-02T00:00:00").toISOString() },
});
const got = nearestOccurrence(dead, new Date("2027-06-01T12:00:00").getTime());
expect(got?.id).toBe("myevent:k3f9qa2m01#2026-09-01");
});
test("an event that does not repeat has no occurrence to find", () => {
// Its own id is already a row, so the caller opens it directly.
expect(nearestOccurrence(rule({ repeat: null }), Date.now())).toBe(null);
});
});