From 661fc1719b29e01e92fa4756ef92f6ba2dec5ef8 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 27 Aug 2026 02:20:45 +0200 Subject: [PATCH] Derive an id per occurrence, unstorable by construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit myevent:#. The token says which recurring thing, the local day says which time round, and marks, ignores, progress and daily ticks all key off the whole string — so an occurrence carries its own completion and its own streak rather than sharing the rule's. '#' is outside [a-z0-9] and therefore outside CustomEventId, so an occurrence cannot be written back into the store or survive an import. That is the guardrail rather than a code path anybody has to remember, and a test pins it in both directions. Co-Authored-By: Claude Opus 5 (1M context) --- src/shared/recurrence.ts | 79 +++++++++++++++++++++++++++++++++++ test/recurrence.test.ts | 90 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/src/shared/recurrence.ts b/src/shared/recurrence.ts index 5ac3a9f..70d9a9c 100644 --- a/src/shared/recurrence.ts +++ b/src/shared/recurrence.ts @@ -100,3 +100,82 @@ export function comesRoundEarly( if (repeat === null || endsMs === null) return false; return endsMs > addUnits(startsMs, repeat.unit, repeat.interval); } + +/** + * What separates a rule from one of its occurrences. + * + * Deliberately outside `[a-z0-9]`, and therefore outside `CustomEventId`. An + * occurrence id is derived on read and must never be storable: the store holds + * rules, and a suffixed id round-tripping into it would put a frozen copy of + * today's schedule beside the rule that generates it. `validRecords` drops what + * fails the schema, so the guardrail is the regex rather than a code path + * anybody has to remember. `test/recurrence.test.ts` pins it. + */ +export const OCCURRENCE_SEP = "#"; + +/** + * A stable id for one occurrence of a rule. + * + * The rule's token identifies *which* recurring thing, and the local start day + * identifies *which time round*. Both halves matter: marks, ignores, progress + * and daily ticks all key off this string, so an occurrence carries its own + * completion and its own streak rather than sharing the rule's. + * + * The day is read with local accessors because the reader typed a local date + * and `fields()` shows them a local date back. A UTC reading would label some + * occurrences with the previous day for every reader west of UTC. + * + * **Renaming a rule does not move these** — the token is random, exactly as + * `mintCustomEventId` describes. **Rescheduling one does**, and that strands + * the marks under the old ids. That is accepted and warned about rather than + * migrated; see the spec's § 2 and `removeEvent`'s reasoning for the same + * trade. + */ +export function occurrenceId(ruleId: string, startsAtMs: number): string { + const d = new Date(startsAtMs); + const pad = (n: number) => String(n).padStart(2, "0"); + const day = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + return `${ruleId}${OCCURRENCE_SEP}${day}`; +} + +/** + * The rule behind an id, or the id itself when it is not an occurrence. + * + * Total on purpose. Callers hold an id off a row and have no reason to know + * which kind it is — the detail sheet looking up the record to edit is the + * motivating case, and a feed id passing through unchanged is what keeps it + * from needing a branch. + */ +export function ruleIdOf(id: string): string { + const at = id.indexOf(OCCURRENCE_SEP); + return at === -1 ? id : id.slice(0, at); +} + +/** Whether this id names one occurrence of a rule rather than an event. */ +export function isOccurrenceId(id: string): boolean { + return id.includes(OCCURRENCE_SEP); +} + +/** + * Whether a schedule edit would re-key the occurrences it generates. + * + * The anchor and the interval are both halves of every occurrence id, so + * changing either strands the marks stored under the old ones. `until` is not: + * it truncates the series without moving anything already in it, so a reader + * who only sets an end date should not be warned about ticks that are in no + * danger. + * + * Nothing here rewrites a mark. This is what the form asks in order to *say* + * what an edit costs — see the spec's § 2 for why it is told rather than + * migrated. + */ +export function movesOccurrences( + before: { startsAt: string; repeat: Repeat | null }, + after: { startsAt: string; repeat: Repeat | null }, +): boolean { + if (before.startsAt !== after.startsAt) return true; + return ( + before.repeat?.unit !== after.repeat?.unit || + before.repeat?.interval !== after.repeat?.interval + ); +} diff --git a/test/recurrence.test.ts b/test/recurrence.test.ts index f2f4878..40fe56b 100644 --- a/test/recurrence.test.ts +++ b/test/recurrence.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { addUnits, comesRoundEarly, Repeat } from "../src/shared/recurrence.ts"; +import { addUnits, comesRoundEarly, Repeat, isOccurrenceId, occurrenceId, ruleIdOf, movesOccurrences } 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 // UTC+2 in summer, so a step across 29 March 2026 crosses a real transition; @@ -102,3 +103,90 @@ describe("Repeat", () => { expect(Repeat.safeParse({ unit: "fortnights", interval: 1, until: null }).success).toBe(false); }); }); + +describe("occurrence ids", () => { + const RULE = "myevent:k3f9qa2m01"; + + test("suffixes the rule id with the occurrence's own local start day", () => { + const id = occurrenceId(RULE, new Date("2026-09-01T09:00:00").getTime()); + expect(id).toBe("myevent:k3f9qa2m01#2026-09-01"); + }); + + test("the day is the reader's local day, not UTC's", () => { + // 23:30 local on 1 September is 21:30Z — a UTC reading would label this + // occurrence with the right day here, but the reverse case would not, and + // the reader typed a local date. Assert the local reading directly. + const id = occurrenceId(RULE, new Date("2026-09-01T23:30:00").getTime()); + expect(id).toBe("myevent:k3f9qa2m01#2026-09-01"); + }); + + test("the rule id is recoverable, and a plain id is its own rule", () => { + expect(ruleIdOf("myevent:k3f9qa2m01#2026-09-01")).toBe(RULE); + expect(ruleIdOf(RULE)).toBe(RULE); + // A feed id is colon-separated and carries no separator, so it survives. + expect(ruleIdOf("genshin:some-event:2026-09-01")).toBe("genshin:some-event:2026-09-01"); + }); + + test("an occurrence is recognisable as one", () => { + expect(isOccurrenceId("myevent:k3f9qa2m01#2026-09-01")).toBe(true); + expect(isOccurrenceId(RULE)).toBe(false); + }); + + test("an occurrence id still reads as the reader's own", () => { + // isCustomEventId is a startsWith check on the first segment, so lane + // logic, RESERVED_ID_SEGMENTS and "never attributed to a source" all hold. + expect(isCustomEventId("myevent:k3f9qa2m01#2026-09-01")).toBe(true); + }); + + test("renaming a rule cannot move its occurrence ids", () => { + // The title is not an input here, and that is the guarantee: the token is + // random precisely so fixing a typo never costs the marks attached to it, + // exactly as mintCustomEventId describes. + const start = new Date("2026-09-01T09:00:00").getTime(); + expect(occurrenceId(RULE, start)).toBe(occurrenceId(RULE, start)); + }); + + test("CustomEventId REJECTS an occurrence id", () => { + // The guardrail, asserted rather than assumed. '#' is outside [a-z0-9], so + // an occurrence cannot be written back into the customEvents store and + // cannot survive an import if one ever appears in a file. validRecords + // drops what fails this schema, which is exactly the behaviour we want. + expect(CustomEventId.safeParse(RULE).success).toBe(true); + expect(CustomEventId.safeParse("myevent:k3f9qa2m01#2026-09-01").success).toBe(false); + }); +}); + +describe("movesOccurrences", () => { + const rule = (startsAt: string, interval: number) => ({ + startsAt, + repeat: { unit: "weeks" as const, interval, until: null }, + }); + + test("a changed anchor or interval re-keys every occurrence", () => { + const a = rule("2026-09-01T07:00:00.000Z", 2); + expect(movesOccurrences(a, rule("2026-09-02T07:00:00.000Z", 2))).toBe(true); + expect(movesOccurrences(a, rule("2026-09-01T07:00:00.000Z", 3))).toBe(true); + }); + + test("changing only `until` does not", () => { + // It truncates the series; it does not move what is already in it, so no + // mark is stranded and the reader should not be warned that one is. + const a = rule("2026-09-01T07:00:00.000Z", 2); + const b = { + startsAt: "2026-09-01T07:00:00.000Z", + repeat: { unit: "weeks" as const, interval: 2, until: "2027-01-01T00:00:00.000Z" }, + }; + expect(movesOccurrences(a, b)).toBe(false); + }); + + test("adding or dropping a rule entirely counts as a move", () => { + const plain = { startsAt: "2026-09-01T07:00:00.000Z", repeat: null }; + expect(movesOccurrences(plain, rule("2026-09-01T07:00:00.000Z", 2))).toBe(true); + expect(movesOccurrences(rule("2026-09-01T07:00:00.000Z", 2), plain)).toBe(true); + }); + + test("an untouched schedule moves nothing", () => { + const a = rule("2026-09-01T07:00:00.000Z", 2); + expect(movesOccurrences(a, rule("2026-09-01T07:00:00.000Z", 2))).toBe(false); + }); +});