Derive an id per occurrence, unstorable by construction

myevent:<token>#<YYYY-MM-DD>. 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) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-28 05:04:39 +02:00
co-authored by Claude Opus 5
parent dcb9be9f4a
commit 661fc1719b
2 changed files with 168 additions and 1 deletions
+79
View File
@@ -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
);
}
+89 -1
View File
@@ -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);
});
});