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) <[email protected]>
17 KiB
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:
scheduleBossesis 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, andendsAt: nullon 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:<token>#<YYYY-MM-DD> — 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
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.
/**
* 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:<token>#<YYYY-MM-DD> — 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.isCustomEventIdis astartsWithcheck, so lane logic,RESERVED_ID_SEGMENTSand the "their own date is never attributed to a source" guarantee all hold with no change. #is outside[a-z0-9], soCustomEventIdrejects an occurrence id. An occurrence therefore cannot be written back intocustomEvents, 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<string, …>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
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 joinsstate.feed.eventsinApp.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:
- compute
boardWindowfrom the base rows — feed events, plus current + next per rule; - expand each rule across that settled
[min, max], capped; - 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
untilterminates the series;nulldoes 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
CustomEventIdrejects an occurrence id, so one can never round-trip into storage- a
CustomEventwith norepeatfield parses unchanged (the migration guarantee in § 3) boardWindowcomputed from base rows is unchanged by expanding auntil: nullrule into it
7. Docs, as part of the change
docs/DATA-MODEL.md— the derived occurrence key shape, and what a reschedule costsdocs/PRD.md— F13 extended to cover a rule, with the two-ends distinction stateddocs/SOURCES.mdline 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 <n> days <date> <hh:mm> server <url>
hsr Memory of Chaos <n> days <date> <hh:mm> server <url>
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
mergeEventsreconcile 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.