From c76a0b4e70291899a01b96cdd299144963f82f0b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 27 Aug 2026 02:44:51 +0200 Subject: [PATCH] Project an occurrence into the shape every view reads The rule supplies what the thing is; the occurrence supplies which time round. Nothing downstream is told which it is looking at, which is what lets sort, focus, lanes, filters, progress, ignores and the daily checklist work with no narrowing at any call site. The end is always resolved here and never null. A row still carrying the unresolved form would render live-with-unknown-end forever, which is the failure the whole design exists to avoid. Co-Authored-By: Claude Opus 5 (1M context) --- src/shared/custom.ts | 29 +++++++++++++++++ test/custom.test.ts | 75 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/shared/custom.ts b/src/shared/custom.ts index cb8ecc2..c56e253 100644 --- a/src/shared/custom.ts +++ b/src/shared/custom.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { comesRoundEarly, Repeat } from "./recurrence.ts"; +import type { Occurrence } from "./recurrence.ts"; import { EventType, GachaEvent, Precision, slugify } from "./schema.ts"; /** @@ -242,3 +243,31 @@ export function precisionOf(hasTime: boolean): Extract { expect(parsed.success).toBe(true); }); }); + +describe("asOccurrenceEvent", () => { + const repeating = () => + ownEvent({ + 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 }, + }); + + test("carries the occurrence's id and dates, and the rule's everything else", () => { + const rule = repeating(); + const occ = nextOccurrences(rule, new Date("2026-09-15T12:00:00").getTime(), 1)[0]!; + const row = asOccurrenceEvent(rule, occ); + + expect(row.id).toBe("myevent:k3f9qa2m01#2026-09-15"); + expect(row.startsAt).toBe(occ.startsAt); + expect(row.endsAt).toBe(occ.endsAt); + expect(row.title).toBe(rule.title); + expect(row.game).toBe(rule.game); + expect(row.type).toBe(rule.type); + }); + + test("is still the reader's own, and still claims no source", () => { + const rule = repeating(); + const occ = nextOccurrences(rule, new Date("2026-09-01T12:00:00").getTime(), 1)[0]!; + const row = asOccurrenceEvent(rule, occ); + + expect(row.sourceUrl).toBe(null); + expect(row.sourceId).toBe("you"); + expect(row.extractionMethod).toBe("manual"); + expect(isCustomEventId(row.id)).toBe(true); + }); + + test("renaming a rule does not move its occurrence ids", () => { + // The token is random precisely so fixing a typo never costs the marks + // attached to an occurrence. Exercised here rather than against + // occurrenceId, which never takes a title and so could not fail it: this + // path passes the whole rule, so a rename is a real input to the result. + const rule = repeating(); + const renamed = { ...rule, title: "Abyss, actually" }; + const now = new Date("2026-09-15T12:00:00").getTime(); + const before = asOccurrenceEvent(rule, nextOccurrences(rule, now, 1)[0]!); + const after = asOccurrenceEvent(renamed, nextOccurrences(renamed, now, 1)[0]!); + + expect(after.id).toBe(before.id); + expect(after.title).toBe("Abyss, actually"); + }); + + test("a derived end is a real end, so the clock counts down to it", () => { + // The rule stores endsAt: null; the occurrence resolves it. A row reaching + // a view must never carry the unresolved form, or it renders as + // live-with-unknown-end forever — the exact failure this design exists to + // avoid. + const rule = ownEvent({ + startsAt: new Date("2026-09-01T09:00:00").toISOString(), + startPrecision: "exact", + endsAt: null, + endPrecision: "unknown", + repeat: { unit: "weeks", interval: 1, until: null }, + }); + const occ = nextOccurrences(rule, new Date("2026-09-02T12:00:00").getTime(), 1)[0]!; + const row = asOccurrenceEvent(rule, occ); + + expect(row.endsAt).not.toBe(null); + expect(row.endPrecision).not.toBe("unknown"); + const clock = clockFor(row, "europe", new Date("2026-09-02T12:00:00").getTime()); + expect(clock.msRemaining).not.toBe(null); + expect(clock.live).toBe(true); + }); +});