8 Commits
Author SHA1 Message Date
Lucas WintherandClaude Opus 5 3a3b7d9c2f Give a reader their own events back
An event of theirs that had ended was on no surface at all. Every list and
the board drop a row once its end has passed, and settings only counted the
events a game held rather than naming them — so a one-off became unreachable
the day it finished: impossible to edit, and impossible to delete out of a
store nothing else can see. Repeating events escaped only because their
occurrences roll forward.

So settings names them now, under the game they were filed against, each row
opening the same detail sheet a row on the front page does. Nothing about
how they are managed changes; what was missing was the way back to them.

Two things the index has to get right or it leaves the same hole it closes.
The lists hold occurrences, never rules, so a repeating rule's own id opens
nothing — nearestOccurrence bridges that, and answers for a finished series
too by falling back to the first occurrence, since a rule whose `until` has
passed would otherwise be exactly as stuck. And an event filed under a game
we track has no row of theirs to nest under; the form allows that, so those
get their own heading rather than trailing the list and reading as though
they belonged to whichever game came last.

Each row says why it is not on the front page — ended and when, or its
cadence — because a list of bare titles leaves you guessing which of two
entries is the dead one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:14:47 +02:00
Lucas WintherandClaude Opus 5 6dad359b2a Ask the cadence first, then only the dates it needs
The repeat controls sat after the dates and applied to whatever was there,
which meant a weekly chore still had to answer an end date it has no honest
answer to. Asking first inverts that: the cadence decides which dates are
even questions.

A preset carries no window. Weekly means the week is the window, so there is
no end to type and no ignorance to admit — five fields instead of eight, and
it stores exactly what the model already renders as back-to-back
occurrences. A dated recurring event is therefore a custom, which is where
the forever/delay control now lives; a one-off shows no repeat machinery at
all.

cadenceOf derives which of the five a saved event opens in, so a rule made
before this control existed opens in whichever answer describes it. Nothing
about the schema moved.

Switching cadence hides the end date rather than clearing it: hiding a field
and quietly discarding what is in it is how a form loses somebody's work
when they change their mind back.

The duplicate note was found by rendering the form and looking at it. Both
of the older notes explain the end-date field, so a preset — which has no
such field — was showing two sentences that said nearly the same thing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 2c301d466d Measure a cadence instead of asking for one
The repeat control is becoming three answers — never, forever, or after a
delay — and in the last two the form can work the cadence out from dates
the reader has already given. Both reduce to one question: which unit and
interval steps from the anchor to the instant the next occurrence should
open. Forever passes the instant this one closes; a delay pushes it out.

Searched with addUnits rather than divided out of a millisecond span,
because a month is not a fixed number of days and a week across a DST
transition is 167 or 169 hours. Largest honest unit wins, so 1 July to
1 August is "every month" rather than "every 31 days", which would drift
out of step by February.

repeatModeOf derives which state a saved rule is in rather than storing it,
since "forever" and "a delay of zero" are the same rule and remembering
which button produced it would change nothing about a single occurrence.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 b0523a7cb9 Resolve any occurrence back to its rule, and count what a repeat adds strands
The lists only ever build allRows from a rule's first two occurrences
(LIST_OCCURRENCES), but the timeline draws every occurrence the board window
admits. Clicking a bar past the second set openId to an id allRows could not
resolve, so the detail sheet silently failed to open and per-occurrence
completion was unreachable for those bars. occurrenceForId parses the day an
occurrence id names and regenerates just that neighbourhood via occurrencesOf,
so App.tsx can resolve through the rule when the direct lookup misses.

Separately, strandedBy's `if (record.repeat === null) return 0` hid the
warning on exactly the transition where it matters most: a reader marks a
plain event done, then edits it to add a repeat, and the save re-keys every
row from `id` to `id#<date>` with no notice that the mark under the bare id
is about to become unreachable. strandedOccurrences checks the record's own
id when it has no repeat yet, and its next occurrences once it does.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 50121602db Derive the occurrences a rule stands for
An occurrence with no stated end runs until the next one opens. That is the
boundary a bare rotation was missing — docs/SOURCES.md declines to publish
arustats' Abyss openings precisely because nothing bounded them — and here
it is entailed by the interval the reader typed rather than invented for
them. The store still holds endsAt: null; only this projection resolves it.

nextOccurrences returns what has not finished rather than what is running,
so a rule between cycles answers "opens Saturday" instead of vanishing for
its whole off week.

The "ancient anchor" test's expected occurrences now include 31 August: with
a six-year-old anchor at 09:00 and a query window opening at midnight, that
day's occurrence (no stated end, so it runs until 1 September 09:00 opens
the next) genuinely overlaps the window's first nine hours — the same edge
rule the sibling "overlapping at either edge" test exists to prove. The
brief's original expected list omitted it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 12dbbe3228 Fix vacuous tests in occurrence id suite
Replace the local-vs-UTC test with a timestamp that actually straddles UTC
day boundary (00:30 local on 2 September = 22:30Z on the 1st). The original
test was vacuous because both readings agreed on the same calendar day.

Delete the rename-stability test which was a tautology — occurrenceId never
takes a title, so any pure function satisfied it. The real assertion belongs
in a later task where the whole rule is passed in and a rename can be
exercised.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 661fc1719b 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]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 dcb9be9f4a Step a calendar by days, weeks or months
The app can express a window closing and a day repeating; daily.ts counts
in days and stops there. This is the arithmetic under the rung between
them.

Local wall-clock rather than milliseconds, because a reader's own event is
local throughout — a weekly reset set for 09:00 stays at 09:00 across a DST
transition, where adding 7*DAY would move it an hour and drag every later
occurrence with it. Months clamp to the last valid day rather than letting
setMonth roll 31 February into 3 March, which is the same silent shift
readerInstant already refuses on the way in.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00