Commit Graph
351 Commits
Author SHA1 Message Date
Lucas Winther 4257335f8a chore: manually refresh sources 2026-09-08 21:56:29 +02:00
github-actions[bot] 1bbfae3eb0 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 34212259180.
2026-09-08 09:51:25 +00:00
github-actions[bot] 0d72507881 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 34160239024.
2026-09-07 20:39:59 +00:00
github-actions[bot] e7d3d552c1 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 34111797670.
2026-09-07 10:31:01 +00:00
github-actions[bot] 473bcdc7b8 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 34054496893.
2026-09-06 19:19:20 +00:00
Lucas WintherandClaude Opus 5 8f3e851274 report a source that is quiet rather than broken, instead of a bare 0
The build log prints a count per source, and CI now declines to fail on
a page that states its own emptiness — which leaves that source showing
an unexplained 0, reading as exactly the fault the gate just decided it
was not. The zero we are content with is the one that has to say why.

It is also the only line that would ever prompt anyone to ask whether a
lane quiet for a month is a game between patches or a wiki that reworded
the sentence a statesNoEvents check is still matching. Nothing else in
the pipeline can tell those apart.

The test count in the layout block was stale at 898 and moves with these
two commits, so it is corrected here.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:03:46 +02:00
Lucas WintherandClaude Opus 5 1156c358a7 fix: stop failing the build on a page that says it has no events
Infinity Nikki's wiki prints "There are no Events in this category"
between versions, and the refresh runner has honoured that since
2026-09-03 — it stores the empty parse as the source's real answer
rather than letting a quiet lane reach the broken tier. The feed build
never asked, so the same page arrived at CI as parsedCount 0,
indistinguishable from a parser that has stopped reading a redesigned
page, and brokenSources failed every build while every refresh stayed
green.

The runner's verdict cannot travel on its own: only a parser has seen
the page, and by the time brokenSources runs there is nothing left but
the feed. So the fact rides on SourceHealth, defaulted so an older feed
the service worker cached keeps validating and reads as the strict
answer.

Both ends now ask it the same way — of an empty parse only, from the
page's own words only — because a redesign yields zero rows too, and
excusing that is the silently emptied calendar the gate exists for.

The rule sits in a module rather than in build-feed.ts, which writes
public/ and so runs a build if a test imports it. That is how the two
ends drifted apart with nothing to catch it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:02:16 +02:00
Lucas Winther e51585f2b2 chore: manually refresh sources 2026-09-06 17:40:03 +02:00
github-actions[bot] f65c7fc2d5 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 34024890638.
2026-09-06 09:31:55 +00:00
github-actions[bot] 5dd57d3781 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33986194791.
2026-09-05 19:08:58 +00:00
github-actions[bot] 41954044b8 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33957237624.
2026-09-05 09:10:37 +00:00
github-actions[bot] 83312f2016 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33912471169.
2026-09-04 19:42:17 +00:00
github-actions[bot] 5936ea725f chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33859846418.
2026-09-04 09:45:22 +00:00
github-actions[bot] 8073726c4e chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33799403577.
2026-09-03 19:57:39 +00:00
Lucas WintherandClaude Opus 5 b039e44dcc chore(data): refresh the Infinity Nikki snapshot
First successful fetch since 2026-08-26. The page currently lists no events
in either section, so eventCount is 0 and the lane is honestly empty rather
than holding rows that expired on 27 August.

Run by hand with --force, since the six-hour floor was not up and the
source had four failed cycles behind it. robots.txt read normally.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 19:03:39 +02:00
Lucas WintherandClaude Opus 5 4d45635b0e Write down that a source can be empty without being broken
The two preceding changes make sentences in docs/ false. INGESTION.md
described `canParse` as a structural check without saying that "structural"
excludes the presence of a row — which is exactly the drift that let this
bug in — and described the Stage 1 gate as rejecting every empty parse.
SOURCES.md § 11 left a reader to conclude that an empty Infinity Nikki lane
means the source has died.

AGENTS.md gets the rule in both places somebody would look: § Working on
parsers, next to the bullet about a source resting on one row, and § Fandom
where the fourth template is described.

The general lesson is worth more than the incident. Any page that can list
nothing can produce this, so the guidance is about what identifies a page
versus what happens to be on it — not about this wiki.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 18:46:15 +02:00
Lucas WintherandClaude Opus 5 4b2b3d0958 Store an empty parse when the page itself says it lists nothing
Refusing every zero-event body is right when the alternative is silently
emptying a calendar, and wrong when the page has told us it is empty. A
gacha game goes quiet between versions, and read strictly that is a source
failing every cycle until the next patch ships: three of them reach the
broken tier and fail the workflow over a lane that is correctly empty,
while the snapshot being held ages out of date. Infinity Nikki sat in
exactly that state for four cycles.

So an empty parse is stored when the source's `statesNoEvents` vouches for
it, and the cycle counts as confirmed rather than failed. Everything else
is unchanged: a body that parses to nothing on its own still keeps the
previous snapshot, and a first fetch that yields nothing still stores
nothing.

The gate turns on the page's statement, never on the adapter merely being
able to make one — otherwise implementing `statesNoEvents` would quietly
switch the zero-events gate off for that source. A test pins that.

The run says which of the two empties it saw. "0 events — down from 6"
reads as the shape change it is not, and that note is what somebody
checking on a quiet lane actually sees.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 18:45:57 +02:00
Lucas WintherandClaude Opus 5 a73f35b7cd Identify the Infinity Nikki page by its headings, not by finding a row
The wiki emptied both its Current and Upcoming tables on 2026-09-03 — 2.7's
events had ended and 2.8 was not listed yet — and said so in words: "There
are no Events in this category". Nothing else moved; Past Events still
carries twenty tables of the same shape.

`isInfinityNikkiEventPage` looked for a *populated* table, so it read that
as a redesign. The source spent four cycles reporting "the source has
likely been redesigned" at a page nobody had touched, reached the broken
tier, and went on serving a snapshot whose every row expired on 27 August.

A check that reads data cannot tell a rewrite from a quiet week. It now
reads the section headings, which an empty table does not take with it —
either heading rather than both, since requiring the pair would fail the
source over a renamed heading it does not even read, and one already
separates this page from the other three Fandom templates. docs/INGESTION.md
asked for structural checks all along; this one had drifted into content.

`statesNoEvents` then reports the page's own declaration, so the runner can
tell an answer from a failure. It insists on the statement rather than an
empty table, because a redesign yields an empty table too.

The emptied page is pinned as a fixture: an empty answer is a shape, and it
is the one that used to read as a redesign.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 18:45:24 +02:00
Lucas WintherandClaude Opus 5 0809fa1122 Let a parser report that a page states it lists no events
A count of zero has two causes and nothing downstream can tell them apart:
a source whose selectors all broke, and a game that is simply between
patches. Both parse to nothing, so `canParse` plus a row count cannot
separate them — and the pipeline currently has to assume the worst, which
is right for a redesign and wrong for a quiet week.

`statesNoEvents` is the seam for the one thing that can settle it: the
page's own words. Optional, because most pages say nothing either way, and
absent means the strict gate stands.

No parser implements it yet and nothing reads it — the contract widens here
so the parser and the refresh gate can land as their own changes.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 18:44:51 +02:00
Lucas Winther d5301c5ceb Merge branch 'main' of https://github.com/StereotypicalCat/gacha-event-tracker 2026-09-03 18:13:24 +02:00
Lucas Winther 8568be1892 chore: manually refresh sources 2026-09-03 18:08:22 +02:00
github-actions[bot] 61a1b7e532 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33741629116.
2026-09-03 09:57:27 +00:00
github-actions[bot] fe35a639aa chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33675938499.
2026-09-02 19:53:24 +00:00
github-actions[bot] fed76900b5 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33615707656.
2026-09-02 09:44:25 +00:00
github-actions[bot] 2ef9829627 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33552622815.
2026-09-01 19:59:25 +00:00
github-actions[bot] 26e3d9f6e4 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33496426182.
2026-09-01 10:15:32 +00:00
github-actions[bot] ac1ceb4897 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33443803303.
2026-08-31 21:56:37 +00:00
github-actions[bot] a96b9718e5 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33388081400.
2026-08-31 11:41:08 +00:00
github-actions[bot] 2a14c16edf chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33332103597.
2026-08-30 19:53:46 +00:00
Lucas Winther c551ca33ab chore: manually refresh source snapshots. 2026-08-30 12:57:34 +02:00
Lucas WintherandClaude Opus 5 393ea594ea Only warn about staleness for games the reader has switched on
The footer's staleness notice is an instruction, and the only remedy it
offers is "go and check that game's source page". That is not something a
reader can act on for a game they turned off and cannot see a single row
of — and with nineteen lanes, naming eleven they do not play buries the one
they do. A new lane arrives switched off (F8), so an untouched install was
being warned about most of the catalogue.

Scoped in two places, not one: the named list, and the count the
summarising branch measures against. Without the second, that branch would
never fire for a reader with most of the calendar off and they would get a
list where a sentence was the readable answer.

The notice now also states whose games it counted, in the words NextUp
already uses for the same set. Narrowing what the footer measured while
still printing "nothing has refreshed" would turn a claim about four games
into one about eighteen, in the one paragraph on the page whose job is
being trusted about age.

The headline age and the credits stay whole: the first is a fact about the
feed and feeds the bug form, the second is attribution owed to every source
we read regardless of what is on screen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-30 12:56:00 +02:00
github-actions[bot] 8b3d130521 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33306719302.
2026-08-30 10:32:55 +00:00
github-actions[bot] 1ddb1604ab chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33271849258.
2026-08-29 19:49:40 +00:00
github-actions[bot] ea072362fa chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33250736261.
2026-08-29 11:42:39 +00:00
github-actions[bot] 436542e341 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33194957027.
2026-08-28 17:30:41 +00:00
Lucas WintherandClaude Opus 5 ef109f224b Tell a broken source apart from a stale one
CI failed on Infinity Nikki yielding no events, and was wrong to. Its
snapshot parses to six events; every one of them had ended by the morning
the build ran. The parser is fine — `--now 2026-08-14` gives six, today
gives none — and the page simply has nothing current left on it.

eventCount is counted after expired events are dropped, so "this parser has
stopped reading a redesigned page" and "this page's events have all
finished" arrived as the same zero. Only the first means our code is wrong,
and only the first should redden a build. So the feed now records what each
document yields parsed as of its own capture date, before expiry, and the
check fails on that instead.

Nikki cannot refresh itself out of this, either: docs/SOURCES.md records
that Fandom refuses the Actions runner. A lane with an empty calendar is a
real problem, but it is a refresh problem, so it is reported on the build
log and left visible rather than thrown.

parsedCount is nullable and defaulted, never required: the client validates
the whole feed with safeParse and the service worker serves the last feed it
downloaded, so a required field would have made every cached feed fail
validation and taken the offline promise with it. Null also covers a source
whose bytes were never confirmed live — there is no date to parse "as of",
and a build is not failed on missing information.

The rule moved to shared/feed.ts. A test did pin the old one, by grepping
the workflow for the string — which proved the check existed, never that it
was right.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:54:17 +02:00
Lucas Winther 96d5230f29 chore: manually refresh source snapshots. 2026-08-28 05:46:30 +02:00
Lucas WintherandClaude Opus 5 943e1dd802 Let a reader switch off the chores the app invented
Today's dailies carries two different things: each game's standing chore —
"Commissions, resin", a fixed list nobody publishes — and any event with a
checklist. The first is the app guessing at a routine the reader never asked
for, and it was the only part of that strip with no way out.

The switch removes exactly that. Events keep their checklists whatever their
source, including ones the reader added themselves and marked daily, which
was the requirement most at risk of being filtered away by a switch aimed at
something else.

Nothing is discarded. The ticks live under `dailies:<game>`, nothing here
reads or writes them, and review traced every writer of that store to
confirm it — so switching back on restores every logged day and every
streak. Defaulted on, and the default is now pinned, because flipping it is
the one change that would silently empty the strip for everyone.

Gating where the chore is built rather than where it is drawn means the
counts follow for free: "N still waiting on you today" derives from the
items, and a game left with nothing contributes no group, so the strip's own
empty guard drops it rather than leaving a heading with no rows.

1041 tests.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:43:20 +02:00
Lucas WintherandClaude Opus 5 dd8d7577b3 Watch the pref reach the screen, not just the function
Review pointed out that `dailyGroups` was well covered and the wiring to it
was not: passing the wrong pref from App, binding the checkbox to a
neighbour, or hardcoding `true` in Dailies each shipped with the suite
green. Three of those are now caught by rendering the strip and the settings
panel rather than the function behind them — verified by performing the
mutations, not by reading the code.

The fourth, App handing Dailies the wrong pref, is still uncovered: no test
renders App, and closing it needs a feed and a click library this project
does not have.

Also pins the default. Flipping that one line silently empties Today's
dailies for every existing reader, which is the whole argument of the
comment above it, and nothing was watching — `defaults` is exported so the
value and the upgrade path can both be asserted.

And DATA-MODEL's enumeration of the prefs blob had gone stale; AGENTS.md
names it as the doc to update when a stored key moves.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:43:04 +02:00
Lucas WintherandClaude Opus 5 4277c28e50 Let a reader switch off the chores the app invented
Today's dailies carries two different things: each game's standing chore —
"Commissions, resin", a fixed list nobody publishes — and any event with a
checklist. The first is the app guessing at a routine the reader never asked
for, and it was the only part of that strip with no way out.

So the switch removes exactly that. Events keep their checklists whatever
their source, including ones the reader added themselves and marked daily,
which was the requirement most at risk of being filtered away by a switch
aimed at something else.

Nothing is discarded. The ticks live under `dailies:<game>` and nothing here
reads or writes them, so switching back on restores every logged day and
every streak — the same promise `detectDaily` already makes. Defaulted on,
because everyone has these today and a setting that silently removes
something on upgrade is worse than one nobody notices.

Gating where the chore is built rather than where it is drawn means the
counts follow for free: "N still waiting on you today" is derived from the
items, and a game left with nothing contributes no group at all, so the
strip's own empty guard drops it rather than leaving a heading with no rows.

Named for what it removes. A switch called "dailies" would read as broken
while the strip stayed on screen showing the reader's own events.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:34:08 +02:00
Lucas WintherandClaude Opus 5 f324208457 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.

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.

Resolution lives in displayEventFor, with the rule itself as the floor, so a
stored id always names something the reader can edit and delete — including
a rule that generates nothing at all, which review found opening nothing at
all. It is exported rather than inlined because no test in this project
renders the app or can click, which is exactly how a dead button reached
review in the first place.

Each row says why it is not on the front page: ended and when, stopped and
when, or its cadence.

1032 tests.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:30:53 +02:00
Lucas WintherandClaude Opus 5 86ba2ccc1d Make a stored id always name a row
Review found the new settings row opening nothing for a rule whose `until`
precedes its `startsAt`. That parses — nothing ties the two together — and
yields no occurrence at all, so nextOccurrences and the anchor fallback both
come back empty, nearestOccurrence returns null, and openRow cannot resolve
a bare rule id. It was the undeletable record this index exists to rescue,
now with a button that lies about it.

So resolution moves out of App into displayEventFor, with the rule itself as
the floor: a stored id always names something the reader can edit and
delete, whatever the rule does or does not generate. Exported because
nothing here can click and no test renders App, which is exactly how a dead
button shipped — the chain is now covered without a DOM.

Two of the caption tests could not fail, proven by mutation rather than
read: deleting the whole "starts" branch and removing the null-end guard
both left the suite green, because `not.toContain("ended")` was never
watching the branch at risk. They assert what the caption says now.

And a repeating series that has stopped said only how often it repeats — in
the one place whose job is explaining why an event is on no other surface. A
healthy cadence explains nothing; it says when it stopped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:25:37 +02:00
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 8671a309b0 Say that custom still allows an unknown end
The cadence table claimed custom wants "a start, an end, and how it
repeats", while the one-off row above it took care to say the end may be
unknown. Custom offers the same checkbox, so the table understated it — the
same staleness docs/PRD.md was corrected for, in a row written at the same
time and missed.

Found by the re-review.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 33f7c77704 Let a preset actually be saved
Review found the headline feature unreachable. `valid` still demanded an end
date unconditionally, while every sibling rule had been gated on
`datedWindow`. A fresh form starts with endKnown true and endDate empty, so
picking weekly hid both the field and the checkbox and left Save disabled
with nothing on screen a reader could do about it. Editing an existing event
was fine, which is why no test caught it: every static render drives the form
through `initial`, and cadenceOf never returns a preset for an event that has
an end.

So the three states that needed a click to reach are now pure functions that
do not. endStated pins exactly that case. repeatOf takes the cadence and
answers for all five, which also makes `until` surviving the preset path
provable — mutating it away previously left the suite green.

Two more from the same review. The unknown-end note keyed off the cadence, so
custom with the repeat set to never promised a countdown that would never
arrive; it keys off the rule now, which is what actually decides. And a delay
whose start and end disagree about having a time of day can never land a
whole unit from the anchor — unstatable, not merely unstated — so it says so
rather than disabling Save in silence.

"State it myself" was also a one-way door: nothing ever cleared it, so the
measured reading could not be recovered without abandoning the form.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 4251717615 Say what the app actually does now
Four things in here had gone false.

The games table listed six of nineteen and gave per-game event counts, which
change on every refresh — a number in a README nobody updates is a number
that lies, so the counts are gone and the app's own header keeps that score.
Arknights was described as awaiting a source it has had for a while.

Your own games and events had no entry at all despite being shipped, and
neither did the cadence that came with it. Both are now described in the
terms the form uses, including why a preset has no end date to give and why
an occurrence that states no end can still count down.

And the status section claimed the app keeps itself up to date. game8.co
answers the Actions runner with a bot-management 202, so nine of the twenty
sources only move when somebody refreshes them by hand. That is recorded in
docs/SOURCES.md and was the most misleading sentence in the file.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +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 4c3aa4c7e3 Call it a cycle, not "every N days"
"Every 26 days" is ordinary English for an interval, but it sits directly
beneath a start and an end — a duration — and a reader who has just been
thinking in durations reads it as another one. Naming the shape of the
repetition is what separates them, and "cycle" is the word this genre
already uses.

A cycle of one unit is named rather than numbered, because nobody says "a
1-week cycle"; a longer one takes the singular unit, since a hyphenated
"26-day" is an adjective and not a count. The article follows how the
number sounds — an 8-day cycle, an 11-day, an 84-day — which is the kind of
wrong that reads as sloppiness rather than as a bug.

The manual control becomes "Cycle length" for the same reason: it is asking
for the length of the cycle, not for a duration the reader already gave.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 1f1d608e95 Ask how it repeats, not how often
Three answers — never, forever, or after a delay — because "how often" is
rarely the question a reader has. They know it comes back the moment it
ends, or that it comes back after a wait. The cadence follows from that and
from dates they have already typed, so the form reports what it measured
instead of asking for a number, and says it out loud rather than filling a
field silently.

A delay is expressed by where it lands: the next opening is the wait added
to the close, so a delay cannot produce a rule that comes round before it
ends. Only a hand-stated cadence can, and stating one by hand stays
available — measuring is the convenience, not a cage.

Both readings are shown for a delay, since a week's wait after a week's
window is a fortnightly rule and seeing that spelled out is how a wrong
number gets caught.

contiguousOpening is the load-bearing detail. An end given as a date is
stored as 23:59:59, so a successor opening "the moment it closes" opens at
the following midnight, a second later. Comparing against the stored end
instead read every day-precision rule as having a gap it does not have —
and the form has to measure the instants it will save, not the ones the
record happens to hold, or it opens describing a rule it would not write.

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