Record what recurrence changed, and what it unblocked

Three sentences were false once a rule could bound an occurrence. DATA-MODEL
gains the derived occurrence key and what a reschedule costs; F13 gains the
rule and the reason an occurrence need not state its end.

SOURCES' arustats note stays, with the answer beside it: the reason
scheduleBosses is unread was a design gap, that gap is closed, and reading
it is now a parser change. Nothing on the ingest side has moved — this note
is what stops the next person re-deriving why.

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 c5c578ae7e
commit c93f85fb6d
3 changed files with 51 additions and 0 deletions
+26
View File
@@ -502,6 +502,32 @@ There is deliberately **no `dailyTasks` for a custom game**, so it contributes n
reader to invent one is a form to fill in for a reminder they already have. Their events can still be reader to invent one is a form to fill in for a reminder they already have. Their events can still be
marked as repeating, per event, like any other. marked as repeating, per event, like any other.
### Occurrence ids
A reader's event may carry a repeat rule. The rule is what is stored; its
occurrences are derived on read and never written.
myevent:<token> the rule, as stored
myevent:<token>#YYYY-MM-DD one occurrence, derived
The suffix is the occurrence's own start day, read in the reader's local
timezone — the same reading `readerInstant` wrote it with and `fields()` shows
back. `#` is outside `[a-z0-9]` and therefore outside `CustomEventId`, so an
occurrence id cannot be written into `customEvents` and cannot survive an
import; `validRecords` drops it. That is the guardrail, and
`test/recurrence.test.ts` pins it in both directions.
Marks, ignores, progress and daily ticks key off the whole string, so each
occurrence carries its own completion and its own streak.
**Rescheduling a rule strands its marks.** Moving the anchor start or the
interval re-keys every occurrence; the marks under the old ids stay in
localStorage and stop being reachable. This is not migrated, for the reason
`removeEvent` does not cascade and `useMarkSet.merge` never removes — nothing
else holds a copy, so a silent rewrite is unrecoverable. The edit form counts
what will be stranded and says so first. Renaming a rule costs nothing: the
token is random.
**Deleting.** Removing your own event leaves any marks and logged days behind rather than reaching **Deleting.** Removing your own event leaves any marks and logged days behind rather than reaching
into three other stores on a single tap; they are inert, and the alternative is a misclick that into three other stores on a single tap; they are inert, and the alternative is a misclick that
deletes a streak. Removing a game that still has events is refused and says how many, rather than deletes a streak. Removing a game that still has events is refused and says how many, rather than
+15
View File
@@ -323,6 +323,21 @@ Four constraints, each protecting something that already exists:
which is the same argument the code already makes for streaks. This is the *only* copy — there is which is the same argument the code already makes for streaks. This is the *only* copy — there is
no server to restore from. no server to restore from.
A reader's event may also state how it comes round again — every N days, weeks
or months, optionally stopping on a date. The rule is stored; its occurrences
are derived, and each one is an ordinary event everywhere in the app: its own
countdown, its own completion, its own daily checklist.
**An occurrence need not state its end.** With none, it runs until the next one
opens. That is not the app inventing a date to fill a form — it is entailed by
the interval the reader typed, and it is what separates a rule from the
unbounded rotation § Quality bar refuses to publish. It also means a plain
"resets every Monday" needs no end date at all.
The lists carry two occurrences of any rule — the one that has not finished and
the one after it — because they answer "what ends soonest". The timeline draws
every occurrence in view, because it answers "what is the rhythm".
**F8 — First-run setup.** **F8 — First-run setup.**
Before any events are shown, the reader picks which games they play, and how they want to read Before any events are shown, the reader picks which games they play, and how they want to read
them. A calendar full of games they them. A calendar full of games they
+10
View File
@@ -741,6 +741,16 @@ only exactly-dated material on the page (Abyss and Memorial Arena openings, thre
deliberately unread: a recurring rotation with no end is not a deadline, and `endsAt: null` on each deliberately unread: a recurring rotation with no end is not a deadline, and `endsAt: null` on each
would render them live-with-unknown-end forever. would render them live-with-unknown-end forever.
**As of 2026-08-27 the blocker above is addressable.** `src/shared/recurrence.ts`
gives an event a repeat rule, and a rule supplies the boundary the rotation was
missing: an occurrence with no stated end runs until the next one opens, so
`endsAt: null` no longer means live-with-unknown-end forever. Reading
`scheduleBosses` is now a parser change rather than a design question, and it is
Phase B's first target in
`docs/superpowers/specs/2026-08-27-recurring-events-design.md`. Nothing has been
changed on the ingest side yet — `GachaEvent` does not carry a rule, and this
note is what stops the next person re-deriving the reason it does not.
## What every one of these costs, beyond the source ## What every one of these costs, beyond the source
Adding a game is never only a `SOURCES` entry: Adding a game is never only a `SOURCES` entry: