First-run setup marks a new reader up to date, since changes mean nothing
before you've used the app. An absent marker therefore means an install
from before the changelog, which is what lets the launch be announced to
existing readers once.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Both prefs are absent unless the reader answers, so built-ins are opt-in
for every install, old ones included. The question is asked only about a
game on screen, and only once. A built-in the reader made their own
stays hidden while the copy exists.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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]>
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]>
Nothing did. Every surface that lists a game rendered App's `games`, which is
the order lanes first appear in the feed — whichever game happened to hold the
first event row. It is arbitrary, it moves as events come and go, and it was
the same complaint the first-run picker had.
So `orderGames` is one rule in one place, and `prefs.gameOrder` is where the
reader's own answer goes. Absent means they have never placed a game rather
than an empty order, which is the distinction `knownGames` already draws and
the same trap: every install predating the field is in that state, and reading
it the other way would hand them a blank list. They get A–Z by the name on
screen instead.
Two properties carry the weight, and both are tested rather than asserted. The
result is always a permutation of the lanes given, because a game dropped
here looks exactly like a game switched off and switching it on would not
bring it back. And a lane the order does not name trails the ones it does, so
a game we add later never lands in the middle of a hand-made order and a
retired source keeps its slot for when it returns.
Nothing reads it yet.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
It was the board's alone, sitting between two app-wide filters and
carrying a line of small print to explain why it was different. It is the
same events answering the same question in either view, so it is now one
switch: the board plots them, and the checklist keeps its "Not started
yet" section.
That means the section is off by default, which is a visible change for
every existing reader — deliberate, and the same argument the board makes.
This app answers what expires next; on fourteen lanes the queued patches
are more rows than the thing they came for.
`timelineUpcoming` becomes `showUpcoming`, because the old name would now
be false, and `adoptRenamed` carries a stored answer across on load.
Nothing would be lost by dropping it — `prefs` is one blob under one key,
not a key space — but a reader who had switched the future on would find
it off with no explanation, and they should not have to say a thing
twice. A stored new name always wins, so it cannot overwrite a fresher
answer with a stale one.
Gating the section alone would have opened a hole: nothing running and
everything held back rendered an empty column, because the "nothing to
show" line keyed off there being no rows at all rather than none listed.
It now counts what is held back and names the switch, as the board does.
The split pills stay the board's: the checklist gives these a section
with a heading either way, so there is nothing there to mix them into.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Whether unstarted events keep to their own block or sit in one deadline
order with the running ones. Both are right for somebody, so it is the
reader's answer and it survives a reload like the rest of the board's
shape does.
Defaults to the block, which is the board as it was before the choice
existed. Only read while `timelineUpcoming` is on — with nothing
unstarted plotted there is no block to keep apart — but stored either
way, so switching that back on restores what they said rather than a
default.
The Controls test builds a whole `Prefs`, so a new required field lands
there in the same commit or nothing typechecks in between.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The timeline draws its window from what it plots, so a lane's next patch
sitting on the board pushes every running bar right to make room for
things nobody can do yet. That wants to be the reader's answer rather
than ours, and it wants to survive a reload like `view` and
`timelineGroup` do.
Defaults to false, which is what every existing board already looks
like — the rows were there but nothing was asked, so shipping this moves
nobody. A stored pref wins either way.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`prefs.theme` is dark, light or system, defaulting to dark: a reader
whose laptop is in light mode has said something about their laptop, not
about this page, and following the device would move every existing
reader the first time they loaded this. No control yet — the value can
only arrive from storage.
App resolves it and writes it to the document. It is also where a hue
meets the theme: doing that in the lane resolver means every label, chip,
rail and bar gets the readable answer without a component knowing a theme
exists, which is why the colophon moves onto that resolver too.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Lanes keep a game's events adjacent, which is what makes the board
readable for someone playing four of them — but a reader with four games
has one queue of deadlines, and lanes scatter it: the thing ending
tonight sits three lanes below the thing ending next month, and no
amount of scrolling puts them side by side. So the stacking becomes a
choice, and the choice is remembered like the scale and the view are.
The merged mode sorts with endingSoonestFirst rather than a bare end
date, so the timeline and the list cannot mean different things by the
same words — and an unannounced end keeps its place behind every dated
one instead of pretending to a deadline.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
One density cannot answer both questions the board is asked. A patch cycle is
six weeks, a login campaign runs for months, and 13px a day is a compromise
between "what am I in the middle of this week?" and "how do the next three
months line up?" that serves neither well.
A pair of controls steps through a ladder of day widths and the choice is
remembered, on the same argument as the view tabs: a reader who has said how
they want to read this should not say it again on the next load.
Two things it had to get right. Zooming holds the middle of the view still —
rescaling around the left edge of a three-month board throws away whatever
they had scrolled to, and re-opening at today would undo the scrolling that
got them there. And the dated ticks thin out as the scale shrinks, because a
week is 42px at the widest setting and the dates would sit on top of each
other; the gridlines stay weekly, since they carry the rhythm rather than the
reading.
The scale is stored as the measurement, not a step number, and read through
`snapDayWidth`. An export written against a different ladder then opens on
something close to what its reader chose, and a corrupt value opens on the
default rather than a board one pixel wide.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adding a source is our decision, not the reader's. Eleven lanes became
fourteen this week, and for someone who plays two that is not a feature
arriving — it is their calendar filling with events they will never open,
which is the thing the first-run picker exists to prevent.
`knownGames` records every lane a reader has been offered; a lane missing from
it is new to them, so it is recorded and hidden on sight. The games chips in
settings list every lane, on or off, which is where they take one up.
The case that had to be right is the reader who installed before any of this
existed: they have no `knownGames` at all, and reading that as "has been
offered nothing" would switch off every game they already read. Absent means
unrecorded — the first pass records what is already on their screen and
changes nothing else. Lanes they invented are recorded but never hidden;
typing a game in is asking for it.
The decision is a pure function so this is provable rather than watched for.
One real cost, stated in the PRD rather than hidden: a reader whose game
finally arrives is not told, which makes the colophon roadmap matter more.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`view` was component state, so a reader who preferred the timeline was put
back on the list by every reload — and which view opened at all had been
decided for them twice over: the PRD said calendar, the app shipped the list.
Neither was the reader's answer.
So the first run asks, and the tabs write to `prefs` from then on. The
question ships pre-answered with the list, because a reader cannot choose
between two layouts they have not seen and that is the one that answers "what
expires next" in a look — and each option is drawn rather than described, for
the same reason. The screen says where to change it afterwards, since the tabs
are small text in a corner.
Adds `view` to the prefs key space. Additive and defaulted, so an existing
reader's stored prefs open exactly where they did before.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The data layer for PRD F13, with no UI and no behaviour change yet.
src/shared/custom.ts defines the two key spaces, their schemas, and the
projection into what the views read. Two properties are the point of it:
- A reader's event id is random, not derived from their title. They can type a
scraped event's exact name and date, which under ${game}:${slug}:${date} is a
byte-identical key — one completion mark and one streak silently shared by two
events. Randomness also means renaming their own event never moves its id.
- A reader's event carries no sourceUrl, so a hand-entered date can never be
attributed to a source, and claims no region split, because they entered one
instant and inventing three would fabricate two of them.
The rest is widening what was GameId-shaped into a lane that may be one of
theirs: clockFor takes the boundary fields structurally so their events run on
the identical countdown rather than a second one, day keys fall back to the
regional default for a lane with no server map, and gameMeta becomes a context
resolver so metaFor stays pure and total — a lane can outlive its game when an
import carries an event whose game did not come with it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Dailiness is guessed from the source's wording, which is a weak signal
and wrong in both directions. Guessing on a reader's behalf and hoping
they notice is the wrong default for a checklist they may build a streak
on, so `detectDaily` now starts false and the control carries an
Experimental tag saying what it does and doesn't know.
The default applies to new readers only — a stored `prefs` keeps its
value, because switching it off under someone who has been ticking
auto-detected checklists would pull those chips out of the strip with no
explanation. Nothing is deleted either way.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Switching games on and off says which games you play, and is set once.
It's the wrong tool for what a player of four games does while reading —
clear one game, move to the next — which cost two taps per game and left
the settings panel no longer describing what they play.
Focus is a lens over that filter, not a second filter: a bar at the top,
above everything it narrows, with a "next game" control that steps
through and ends by returning to all. It never touches hiddenGames, and
a focus on a game since switched off is ignored rather than obeyed, so
it can't strand you on a blank page whose cause is elsewhere. Each chip
carries that game's outstanding count, so a game with nothing waiting
says so before you visit it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Guessing from a source's wording is right most of the time, and someone
who finds it wrong often enough should be able to stop it rather than
dismiss the same false positive every patch.
Off, only events the reader marked themselves get a checklist. It
silences the guess rather than deleting anything: overrides, ticks and
streaks all survive, so switching it back on restores exactly what was
there. With it off, marking an event stores an explicit yes, since there
is no longer a detection for an override to agree with.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
"What runs out first" is the question this app exists for, but it is not
the only one a reader arrives with. The other is "what was I in the
middle of?", and until now the list could not answer it.
Two modes, toggled from the list's own header rather than from settings,
because ordering is something you reach for while looking at a list.
Ticking a day off a daily counts as doing it without the reader also
setting the status — the tick already said so.
Sorting only ever groups. Both modes fall back to endingSoonestFirst
inside a group and live still beats upcoming, so choosing an order can
never cost the reader the deadline order the product is for.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Ignoring is not completing. "Done" keeps an event visible and counted;
"not interested" removes it from both views, which is the entire point.
Completions and ignores are the same shape and want the same guarantees, so
they now share one mark-set implementation — union merge on import, never a
removal, since nothing else holds a copy. Both ride in the export file.
Ignored events stay recoverable: the count and a reveal toggle appear in
settings once there is something to reveal.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A calendar full of games you don't play is worse than an empty one — it
buries the thing you came for.
Nothing is preselected; the button stays disabled until a game is picked,
which is clearer than guessing and hoping the reader notices. The choice is
stored as hiddenGames, the inverse, so a game added later shows up by
default rather than staying invisible.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Completions and prefs live in localStorage and never reach the server —
there is no account and nothing to log into. Reads never throw, so a corrupt
value costs a preference rather than the whole screen.
Import merges and never removes: a completion present on either side stays
completed, because nothing else holds a copy to restore from.
The feed client refuses a schemaVersion it does not know rather than
guessing at unfamiliar fields.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>