From 98cd950e8e8b712e0b11588016d609e7aea84507 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 27 Aug 2026 01:53:43 +0200 Subject: [PATCH] Design recurring events, and plan the sourced ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app can express a window closing and a day repeating, and nothing in between — daily.ts counts in days and stops there. That gap is already costing parsed data: arustats scheduleBosses is read and discarded because "a recurring rotation with no end is not a deadline" (docs/SOURCES.md:740). A rule fixes exactly that. Content repeating every fourteen days ends, at the latest, when its next occurrence opens — a boundary entailed by the interval rather than invented for a form. So an occurrence may state no end and still be a deadline, which is the distinction the spec turns on. Proposes it first on the reader's own events (F13), where the rule is typed rather than fetched: no parser, no review gate, no GachaEvent change, and a tested recurrence model in shared/ for the ingest side to adopt later. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-27-recurring-events-design.md | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-27-recurring-events-design.md diff --git a/docs/superpowers/specs/2026-08-27-recurring-events-design.md b/docs/superpowers/specs/2026-08-27-recurring-events-design.md new file mode 100644 index 0000000..eedfb41 --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-recurring-events-design.md @@ -0,0 +1,318 @@ +# Recurring events: the reader's own, and a plan for sourced ones + +**Date:** 2026-08-27 +**Status:** proposed + +Read `AGENTS.md` first. Every rule cited below is already written down there or in `docs/`, and the +citations are load-bearing rather than decorative. + +## Why + +This app measures windows closing. It has exactly one vocabulary for something that repeats — +`src/shared/daily.ts` — and that vocabulary only counts in days: `dayKey`, `nextResetMs`, +`dailyDays`, `streakOf`. It is good at the thing it does. It cannot express a fortnight. + +That gap is not hypothetical, and it is already costing us data we have parsed and thrown away: + +> `scheduleBosses` is the only exactly-dated material on the page (Abyss and Memorial Arena +> openings, three a week) and is 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. +> +> — `docs/SOURCES.md` § arustats.com, line 740 + +The reasoning there is sound *for an undated rotation*. It stops being sound the moment something +carries a **rule**, because a rule supplies the boundary the rotation was missing: content that +repeats every fourteen days ends, at the latest, when its next occurrence opens. That is not a date +invented to fill a form — it is entailed by the interval. + +So the missing primitive is a repeat rule, and the cheapest honest place to prove it is the surface +where the reader supplies the rule themselves (PRD F13). No fetching, no parser, no review gate, no +`GachaEvent` change — and at the end of it, a tested recurrence model that the ingest side can adopt +when Phase B below is started. + +Three things a reader cannot record today, all of them the same shape: + +| What | Why it fails now | +|---|---| +| Weekly missions, resets Monday | One-off event or nothing. Ticking it re-types it every week | +| Spiral Abyss, 1st and 16th | Two events a month, entered by hand, forever | +| Battle pass, every version | A 42-day window that has to be re-entered every 42 days | + +## Decisions taken + +| Question | Answer | +|---|---| +| What does the reader express? | One concrete window, plus how it repeats | +| Repeat units | Every N days, N weeks, or N months. `N` is 1–365 | +| Does the rule ever stop? | Optionally. `until` is nullable and defaults to never | +| Must an occurrence state its end? | **No.** With no stated end it runs until the next occurrence opens | +| How many occurrences are visible? | Lists: the running-or-next one, plus the one after. Timeline: every one inside the board window | +| Are occurrences stored? | Never. One rule persists; occurrences are derived on read | +| Occurrence ID | `myevent:#` — the rule's token, the occurrence's start day | +| Where do completion marks attach? | Per occurrence. `marks`, `ignored`, `progress` and `daily` are unchanged | +| Rescheduling a rule orphans its marks | Accepted, and warned about before saving. Nothing is rewritten | +| Does `GachaEvent` change? | No. Nothing in the ingest pipeline, the API contract or the review gate moves | + +## The two ends, which are not the same end + +The design turns on a distinction that is easy to lose: + +- **the rule's end** — `until`, the instant repetition stops. Nullable. A rule may repeat forever. +- **the occurrence's end** — when *this* window closes. Also nullable, and this is the interesting + one. + +An occurrence with no stated end is **not** the `endsAt: null` case `docs/SOURCES.md` refuses. That +case is unbounded because nothing bounds it. Here the interval bounds it: the window runs from its +anchor to the instant the next occurrence opens, and that instant is derived from a number the +reader typed. `endsAt: null` / `endPrecision: "unknown"` is what gets **stored** — nothing is +fabricated in the store, and the existing `CustomEvent` refine pairing the two is untouched. The +boundary is resolved at projection time, inheriting the precision of the anchor start. + +One consequence worth stating: a rule with no stated end produces **contiguous** occurrences, back +to back with no gap. That is exactly the shape a reset-to-reset chore wants ("weekly missions, +resets Monday"), and it falls out of the same model rather than needing a second kind. + +## 1. `src/shared/recurrence.ts` — new, pure, no React + +```ts +export const RepeatUnit = z.enum(["days", "weeks", "months"]); + +export const Repeat = z.object({ + unit: RepeatUnit, + /** How many units between one occurrence opening and the next. */ + interval: z.number().int().min(1).max(365), + /** When repetition stops. Null means it does not. */ + until: z.string().datetime().nullable(), +}); +``` + +Anchored to the event's own `startsAt`; occurrence *n* opens at anchor + n·interval. The window's +duration (`endsAt − startsAt`) is held constant and slid forward, when a duration is stated at all. + +**Month stepping clamps, never rolls over.** 31 January + 1 month is 28 February, not 3 March. +`readerInstant` already guards `Date.parse`'s silent rollover for exactly this reason, and the same +rule applies here: a date that quietly moves is the one thing this codebase does not ship. + +**Duration ≤ interval span**, checked only when an end is stated. A 14-day window repeating every 7 +days overlaps itself, which makes "what ends soonest" ambiguous and puts two live occurrences of one +rule in the list at once. Rejected at the form, and by a `.refine` on the schema so an imported file +cannot carry one in. With no stated end there is no duration to overlap and the check does not apply. + +```ts +/** + * Every occurrence opening inside [fromMs, toMs], oldest first. + * + * Clock-injected and capped, like everything in daily.ts: a 1-day interval over + * a year of board window is 365 rows, and a corrupt interval must not be able to + * allocate without bound. MAX_OCCURRENCES mirrors that module's MAX_DAYS. + */ +export function occurrencesOf( + event: CustomEvent, + fromMs: number, + toMs: number, + cap?: number, +): Occurrence[]; +``` + +An `Occurrence` carries a resolved `startsAt`, a resolved `endsAt` (stated duration, or the next +opening), the precision each was resolved at, and the derived id from § 2. + +## 2. Occurrence IDs + +`myevent:#` — the token identifies the rule, the suffix the occurrence's own +start day, in the reader's timezone as they typed it. + +Three properties, all deliberate: + +- **The first segment is still `myevent`.** `isCustomEventId` is a `startsWith` check, so lane + logic, `RESERVED_ID_SEGMENTS` and the "their own date is never attributed to a source" guarantee + all hold with no change. +- **`#` is outside `[a-z0-9]`, so `CustomEventId` rejects an occurrence id.** An occurrence + therefore cannot be written back into `customEvents`, and cannot survive an import if one ever + appears in a file. That is a guardrail rather than an accident, and § 6 pins it with a test. +- **Marks, ignores, progress and daily ticks need no change at all.** All four are + `Record` keyed by an opaque event id (`useMarkSet`, `useProgress`, `useDailyLog`). Each + occurrence gets its own completion, its own ignore, and — where the event reads as daily — its own + checklist and its own streak. + +**Renaming a rule does not move its ids.** The token is random for precisely this reason, already +documented on `mintCustomEventId`: "it does not move when they rename their own event, so editing a +typo in a title never costs them the marks attached to it." + +**Rescheduling a rule does move them, and that orphans its marks.** Moving the anchor start, or +changing the interval, gives every subsequent occurrence a different suffix. The old marks stay in +`localStorage` under keys nothing points at any more, so a cycle the reader ticked reads as never +done. + +This is accepted rather than migrated, because it is the stance the codebase already takes for this +class of change. `removeEvent` leaves marks behind on purpose — "Reaching into three other stores on +a single tap is how a misclick costs someone a streak, and an orphaned mark costs them nothing" — +and `useMarkSet.merge` never removes, because "nothing else holds a copy of these, so a silent +deletion would be unrecoverable". A migration that re-keyed marks by ordinal would be exactly the +cross-store reach both of those comments decline, performed on an edit the reader may well be +part-way through experimenting with. + +What changes instead is that it stops being silent: the edit form counts the marks that will be +stranded and says so before the reader saves, the way `removeGame` reports `blockedBy` rather than +cascading. + +## 3. `CustomEvent` gains one optional field + +```ts +repeat: Repeat.nullable().default(null), // absent on every event that exists today +``` + +`.default(null)` and not merely `.nullable()`, because a stored record written before this field +existed has no `repeat` key at all, and a bare `.nullable()` rejects a *missing* key rather than +supplying one. Defaulted, every record already in a reader's `localStorage` parses unchanged — +`readValid` drops what does not parse, and a required field here would silently delete every custom +event on the device on first launch. `docs/DATA-MODEL.md` § Event IDs are localStorage keys is the +same warning in the same place. + +`asDisplayEvent` is unchanged for a non-repeating event. A repeating one goes through +`occurrencesOf` first and each occurrence is projected individually, so a `DisplayEvent` reaching a +view never knows it came from a rule — which is what keeps sort, focus, lanes, filters, progress and +the clock working with no narrowing at any call site. + +## 4. Expansion, and the circularity that has to be broken first + +Two call sites, deliberately different: + +- **`useCustom.rows`** — for each rule, the occurrence that is running *or*, if the rule is + between cycles, the next one to open; plus the one after that. Always two, never fewer while the + rule is still repeating, so a gap between cycles reads as "opens Tuesday" rather than as nothing. + This is what + joins `state.feed.events` in `App.tsx:198`, so the lists that answer "what ends soonest" gain + exactly two rows per rule however often it repeats. +- **`useCustom.occurrencesIn(min, max)`** — every occurrence in a range, for the timeline, where + repetition is the reading rather than clutter. + +**`boardWindow` must not see the expanded rows.** It derives the board's range *from the rows given +to it* (`Timeline.tsx:515`): `max: Math.max(...ends, now) + 2 * DAY`. Feeding it occurrences that +were generated to fill the board window is circular — each pass widens the window, which generates +more occurrences, which widens it again. A rule with no `until` never terminates. + +So the order is fixed and worth a test of its own: + +1. compute `boardWindow` from the base rows — feed events, plus current + next per rule; +2. expand each rule across that settled `[min, max]`, capped; +3. draw. + +A rule can then fill the board but can never enlarge it. The reader zooming out sees more +occurrences of a rule they already had on screen; they never see the board grow because a rule +exists. + +## 5. UI + +**`EventForm`** gains one block under the existing date fields: + +``` +Repeats ▸ never | every [N] [days ▾ weeks ▾ months ▾] +Until ▸ [ ] stops repeating on [date] +``` + +`never` is the default and preserves today's form exactly. The "I don't know when it ends" checkbox +stays available with a repeat set, and gains a line explaining what it now means — *"each one runs +until the next opens"* — because that is a real and non-obvious answer rather than a degraded one. + +**`EventDetail`** gains one line: *"every 2 weeks · next opens 15 Sep"*, and for an unstated end, +*"runs until the next one opens"* beside the countdown rather than the existing "no end date +announced", which would be false here. + +**The reschedule warning** from § 2: when editing a rule whose schedule changed and whose +occurrences hold marks, the save button is preceded by *"changing the schedule will strand 3 ticks"*. +It informs; it does not block. The reader's data is theirs to reorganise. + +No new screen, no new flow, no second kind of thing in any list. + +## 6. Tests + +TDD, pure functions first, clocks injected — `test/` already holds this shape for `daily.ts`. + +- month-end clamping (31 Jan → 28 Feb, and in a leap year → 29 Feb) +- a DST crossing does not shift an occurrence's wall-clock start +- stated duration ≤ interval accepted; overlapping rejected +- no stated end ⇒ occurrences are contiguous, each ending exactly as the next opens +- `until` terminates the series; `null` does not +- the horizon cap holds for a 1-day interval over a year of board window +- **ids are stable under rename and move under reschedule** — both directions asserted +- **`CustomEventId` rejects an occurrence id**, so one can never round-trip into storage +- a `CustomEvent` with no `repeat` field parses unchanged (the migration guarantee in § 3) +- `boardWindow` computed from base rows is unchanged by expanding a `until: null` rule into it + +## 7. Docs, as part of the change + +- `docs/DATA-MODEL.md` — the derived occurrence key shape, and what a reschedule costs +- `docs/PRD.md` — F13 extended to cover a rule, with the two-ends distinction stated +- `docs/SOURCES.md` line 740 — record that the stated blocker is now addressable, and why + +--- + +# The forward plan: recurring events from sources + +Nothing below is part of the work above, and none of it should be written into `docs/INGESTION.md` +or `docs/SOURCES.md` until the phase it describes is actually started. It is recorded here so the +order is deliberate rather than rediscovered. + +The model above is the prerequisite for all of it: `Repeat` lives in `src/shared/` from day one, so +adopting it on the ingest side is a schema addition rather than a second implementation. + +## Phase A — a curated cycle table + +A per-game table of known rotations, in the shape `games.ts` already uses for `dailyTasks`, but +carrying dates and therefore held to a higher bar than a hint string is. + +Illustrating the shape only — the anchors and intervals below are what Phase A has to *establish* +from sources, not values this spec asserts: + +``` +game rotation interval anchor cited page +genshin Spiral Abyss days server +hsr Memory of Chaos days server +``` + +**Every entry cites a source URL and lands as `extractionMethod: "manual"` with a real `sourceUrl`.** +That is existing vocabulary, not a new exemption: the review gate already understands a +human-asserted event, and PRD § Quality bar's "every event links to its source so a skeptical user +can verify in one click" is met literally rather than waived. A table entry that cannot cite a page +does not get added. + +What this buys: the biggest games covered immediately, with no fetching and no parser, because their +cycles are stable and publicly documented. + +What it costs, and must be written down when it ships: **it goes stale silently.** A game changing +cadence breaks the table with nothing to detect it. Mitigations to decide at the time — a +`verifiedAt` per entry that the colophon surfaces once it is old, or a test that fails when an entry +has not been re-checked in N months. The former is more honest; the latter is more likely to work. + +## Phase B — parsers emit `Repeat` + +`GachaEvent` gains the same nullable `repeat` field, and parsers that can state a rule do. + +**Start with arustats `scheduleBosses`.** It is already fetched, already parsed, and currently +discarded at `parsers/arustats.ts` — the cheapest possible first proof, and it directly retires the +note at `docs/SOURCES.md:740`. + +Open questions to settle before starting, not now: + +- Does the review gate treat a *rule* differently from a date? A wrong interval is wrong forever, + where a wrong date is wrong once — that argues for a lower confidence ceiling on a parsed rule, + or for routing every new rule through quarantine regardless of score. +- How does `mergeEvents` reconcile two sources stating different intervals for one rotation? The + existing confidence preference is probably right, but it has never had to compare rules. +- A source that revises a rule mid-series moves occurrence ids and orphans marks, the same hazard + as § 2 but without a reader present to be warned. This one needs an answer before Phase B ships. + +## Phase C — battle passes and weekly task lists + +Deliberately last. Both are recurrences whose *content* changes each cycle even though the window +does not, and this app is explicitly "not a wiki" (`docs/PRD.md` § What this app is not) — it says +that something exists and when it ends, not what is in it. So the window is in scope and the reward +list is not, and that boundary should be agreed before any of it is built rather than discovered +half-way through a parser. + +## Out of scope throughout + +Notifications of any kind, including a browser-local reminder when a cycle opens. `docs/PRD.md` +§ What this app is not still holds, and a repeating event is exactly the feature that makes +reminders tempting.