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:
co-authored by
Claude Opus 5
parent
dcb9be9f4a
commit
661fc1719b
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user