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]>
319 lines
17 KiB
Markdown
319 lines
17 KiB
Markdown
# 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:<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
|
||
|
||
```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:<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`.** `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<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
|
||
|
||
```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 <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 `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.
|