expectedForMonth mirrors chargesForMonth for money that has not moved yet, and
skips confirmed occurrences — those are ordinary purchases that chargesForMonth
has already counted, so counting both would double every settled bill.
The split machinery is extracted rather than duplicated. Two implementations
could round differently or absorb the remainder differently, and the gap would
show up as a penny of drift between the fold's two tracks with no cause the
owner could find. remainderIndexOf and splitPartsOfTotal keep their signatures
as wrappers, so no existing caller changes.
Smaller than planned: 8d2b2ca already made these functions take digits rather
than a currency table, so the shared helper needs no currency plumbing.
A walk rather than a per-month query: under lastCharge anchoring, where a
series sits in a given month depends on every confirmation before it, so the
projection starts at startDate and steps forward. Bounded by upToMonth, which
the caller supplies — the domain still reads no clock.
The data-cap behaviour is one line. `from` is the confirmation's own date
under lastCharge, so confirming a slot early moves every later occurrence with
it. Confirm the 2026-01-31 slot with a purchase dated the 12th and the next
lands on 2026-02-11, not 2026-03-02.
The walk throws if a step fails to advance. n >= 1 is enforced at both write
boundaries so this should be unreachable, but a hang is worse than a throw and
dropping a bill silently is worse than both.
Rewrote the "deleting the confirming purchase" test per controller ruling: the
brief's version was byte-identical to the unconfirmed-series test above it and
asserted nothing new. It now walks the phone cost with the capHit
confirmations, then walks the same cost with an empty map, and asserts the
dates differ and match the unconfirmed series — which is what "deleting the
purchase restores the series" actually means.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Recurrence is two orthogonal fields, not a list of special cases: a kind
(everyNMonths / everyNDays / everyNWeeks) and an anchoring (calendar /
lastCharge). The phone bill that recharges early when its data cap is hit is
everyNDays(30) anchored to lastCharge — the domain never learns what a data cap
is, because confirming the occurrence early already records the date.
Purchase.source pairs occurrenceDate (identity: which slot this claims) with
Purchase.date (truth: when the money moved). They differ whenever a bill is
paid off schedule, and lastCharge measures from the latter.
RecurringCost.amount is a single mutable Money with no RuleVersion-style
series. Safe only because confirmed purchases hold what was actually paid;
under projection-only it would destroy history.
The 6 -> 7 step writes an empty array and reads nothing. Decimals took 6.
Recurrences of 28 and 30 days do not align to months, so the domain needs
day-level maths for the first time. It gets it the way months.ts does: an
integer ordinal, no Date anywhere. A Date would make 2026-03-29 a different
instant per timezone while being the same day everywhere, and a 28-day step
must not gain or lose a day at a DST boundary.
Hinnant's days_from_civil, which shifts the year to start in March so the leap
day is the last day rather than an insertion, removing every special case.
No isDayGranular export: nothing in the plan consumes it, later tasks branch
on a recurrence kind and import validation uses a regex directly.
`Settings.digits` replaces `CurrencyDef.digits`. Every amount rounds to it,
whatever its currency, and `digitsFor` is gone: the sixteen call sites read the
setting, and the domain functions that took a currency table purely to look
digits up take a `digits: number` instead. Schema 6.
The owner asked for this with the cost in front of them, and the cost is real:
a dataset mixing minor units cannot be described any more. Yen at 0 beside
kroner at 2 has no correct global value — 2 gives ¥1234.56, an amount yen
cannot represent; 0 rounds 49.95 DKK to 50 and drifts the carry from the bank.
Nothing in the app warns. docs/specs/2026-09-02-global-decimals-design.md
argues it in full, including what re-adding a per-currency override would take,
and AGENTS.md §1 now says not to reintroduce one without revisiting the
decision.
The 5 -> 6 step takes the MAXIMUM of the per-currency digits, not the base
currency's. A dataset holding a three-decimal currency already has
three-decimal amounts in it, and adopting the base's 2 would truncate the next
edit of one; the maximum can only keep more precision, never less. On every
dataset this app has written all four currencies record 2, so nothing rounds
differently than before.
Changing the setting does not rewrite stored amounts. 49.95 stays 49.95 and
shows as 50; totals recompute at the new precision. Rewriting each Money is a
destructive whole-dataset write, which §1 owes a backup export first, and it is
lossy one way.
Display turned out to be the hole in the plan. src/ui/format.ts pinned
minimumFractionDigits: 2 in a module-level Intl.NumberFormat, so it never read
the dataset at all — equally broken under per-currency digits, where a JPY
rendered "3,333.00", so a pre-existing gap rather than a regression. Shipping a
Settings control that display ignores would look broken at 0 and be lossy at 3,
hiding precision the dataset holds. So format.ts takes digits like roundMoney
does, useMoneyFormat() binds them for the 64 call sites, and postRowCells takes
the formatter as an argument because it is a plain helper, not a component.
currencyDigits.test.ts is rewritten rather than deleted. Driving a dataset at
ZERO decimals through rounding, both FX directions, both split modes, plan
division, the fold and the month view still tells a real implementation from
one assuming 2 — it just cannot prove per-currency flow, because there is none.
It also asserts that raising the setting to 2 changes what the fold produces.
Verified in Chrome, not only in tests: a v5 dataset with digits 2/0/3 written
straight into IndexedDB migrates to a setting of 3, the table loses its
Decimals column, and editing the control persists schema 6 with digits stripped
from each currency and income left at 20000.55. The same month reads 600/206 at
zero decimals and 600.000/205.561 at three, so the figures recompute rather
than merely reformat.
Not verified here: the .ods in a real spreadsheet.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two fixes that looked like one. The horizontal scrollbar under the currencies
table was not a width problem, and widening alone would never have removed it.
The column: `max-w-4xl` here was 256px narrower than the shell's `main`, which
is already `max-w-6xl`, so the card edges stepped inward whenever you arrived
from the month view. The cap is simply gone rather than raised to `max-w-6xl` —
`main` caps the width and pads it, so a 6xl cap on the child could never bind,
and two caps that agree are still two places to change.
The scrollbar: the row's remove button carries `-mr-2`, so its label sits flush
with the card's content edge rather than 8px inside it. To a scroll container
that overhang IS overflow, and because the table is `w-full` it scaled with the
column — 862 in 854 before, 1070 in 1062 after widening. Exactly 8px at every
width, which is why no amount of widening helped. The wrapper now bleeds 8px
right and pads it back, so the overhang lands inside the padding box: same
table width, same button position, no scrollbar. The same `-mx-2 px-2` shape as
the purchase rows in acb4d97.
A first attempt at diagnosis blamed the table's natural width and was wrong; a
second, zeroing the margin in the page, appeared to disprove the margin too,
because a transition was still interpolating it (-7.73662px when measured).
Waiting out the transition is what made the cause visible.
Verified in Chrome across every route: /settings, /month and /summary have no
scrolling container whose content overflows it. /year still does, by 282px,
which is YearMatrix being genuinely wider than the column — that scrollbar is
doing its job and is left alone.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Dark Reader looks for `<meta name="color-scheme">` first. Finding none, it
waits for a stylesheet and then samples text and background colours at
several points of the screen, plus some further heuristics, to decide
whether the page needs darkening. On the dark palette that verdict is
wrong: it inverts an app that is already dark, a second time.
So the tag now states which scheme is actually live. It ships in the markup
as `light dark`, before any script runs; the pre-paint script narrows it to
the one in use, and `applyResolvedTheme` keeps it there when the toggle
moves — the same two-signal write as the `.dark` class, in both places.
`var dark` moved out of the `try` in that script. It has to be readable
after a throwing localStorage in order to set the meta, and false is the
right value there: it is what `readThemePreference` -> "system" ->
`prefersDark` resolves to on the module side, so the two still agree in the
one case that cannot be read.
Both setters create the tag when it is absent rather than skipping the
write. A silent no-op leaves Dark Reader guessing, which is the bug.
The drift guard now runs the real script, lifted out of the HTML, instead
of matching its text — it asserts class and meta together for a stored
dark, a stored light, and no preference at all. Text matching would have
passed a script that declared the tag and never set it.
Not `<meta name="darkreader-lock">`: that switches the extension off
wholesale, including in light mode, where a user who installed it wants it
working. Only the dark palette asks it to stand down.
Verified in Chrome across stored dark, stored light, system-dark and a live
toggle with no reload: one tag, content and computed `color-scheme`
agreeing each time. Whether the extension honours it needs a human with
Dark Reader installed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three defects in the purchase list, plus NativeSelect for the rule-kind
picker.
Alignment: `px-1` on the row indented every description 4px past its own
date heading, so the list had two left edges 4px apart. `-mx-2 px-2` puts
the text exactly under the heading and bleeds the hover wash outward, so the
highlight has an inset instead of stopping flush against the first
character.
The hover-action reservation: a fixed `w-[7.5rem]` kept the amounts from
shifting when edit/delete appeared, which is right on a desktop. On a phone
it was doubly wrong — there is no hover, so `opacity-0` made these the
hardest controls in the app to reach, and the reservation still spent 120px
of a 350px row hiding them, truncating descriptions to "Weeken…", then
"Bik…", and the financed row to nothing but its badge.
So the row is one line from `sm:` up and two below it: description on its
own line, amount and actions on the next. `sm:contents` dissolves the
wrapper above `sm:`, so the desktop row is the same flat flex line it always
was. This is the call PostTable already makes at that width, for the same
reason.
Also: the month steps wrapped mid-label into "← 2026-" / "08" on a phone,
and the error alert's "dismiss" was an unpadded underlined word.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Section's hint ran the full width of the card — about 160 characters a line
at 12px on a 1140px card, more than twice a readable measure, and the
Currencies and Posts hints both wrapped to three lines that read as a wall
rather than as one aside. Capped at 75ch.
PostTable's "change from here" was a single line of 11px text with no
padding, so a 15px target. It gets padding cancelled by equal negative
margins, so the hit area grows and no baseline moves.
UpdatePrompt used `rounded` (4px) where the card system is 8px, which made
the one panel that appears over everything else the only one with a tighter
radius than its surroundings.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Replaces the two-loose-buttons pattern with Segmented on both, so
spend/balance and by-post/by-month read as two states of one control rather
than a filled primary action beside an unrelated outline button.
Summary also had two row-height defects: "All time" was `sm` in an
`items-end` row of default-height fields, so it sat 4px off their baseline;
and the by-month branch's first cell was `py-2` where every other cell in
the table is `py-2.5`, making those rows 4px shorter than the by-post ones.
The rest of the diff is indentation — both Sections had their children at
the parent's own indent level.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adopts NativeSelect for the base-currency, per-post and rule-kind selects.
Spacing. In Posts the table, the "Archiving hides a post…" footnote and the
add-post row were three siblings with no margin between them at all: the
footnote's own line-height was the only gap, so the three read as one
undifferentiated block. The footnote now has room and the add row is its own
band under a rule. Same for the add-currency row and "Fetch rates now",
which sat 8px under the paragraph explaining it.
Controls. The rule-summary trigger — the only way into a post's allocation
history — measured `h=20 pad=0 rad=0`. It gets a 24px box and the row's
hover wash. `up` / `down` / `archive` were three inline-flex siblings with
nothing between them, so their hover backgrounds touched and the cell read
as one wide slab. "Fetch rates now" was `sm` in a page where every other
section-level action is default height, leaving it 4px short of the fields
beside it.
Also: the add-currency row labelled three of its four fields by placeholder
and the fourth with a visible DECIMALS label, directly below a DECIMALS
column heading that already said it — so that label read as a heading for
the whole row. It becomes an aria-label. "Reset everything…" moves out of
the export group it was fourth in and takes a destructive border.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adopts NativeSelect for the three date/post/currency selects and the split
editor's post picker, and Segmented for the one/many and
percentages/amounts switches. The currency select now matches the Total
input beside it exactly: same height, radius, border and text indent.
The rest is hit areas and edges:
- "set exact date" / "use the month only" were bare buttons with no padding
at all — a 15px target under a 36px field, unhittable on a phone. Now a
24px box, with a negative margin so the text still lines up with the
field's left edge.
- The split row's `remove` and the plan's `remove plan` ran past the
dialog's right padding; both now end on it.
- The bulk-entry remove was a `×` glyph in a 20px box. It uses
lucide's X, the icon family the dialog's own close button already uses.
- The split editor's post select gains `min-w-0` via NativeSelect, so
`flex-1` can shrink below the widest option — the actual cause of the
dialog padding bug fixed in the previous commit.
- The "rounding" radio label gets a real vertical hit area.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
DialogContent is a grid whose single implicit column is `auto`, so it sized
to the widest child's min-content — and one row of the split editor (a
select, an amount, a unit, a radio and a remove button) has a min-content
width of 480px against the 464px the padding leaves.
The column grew past the padding box and every stretched child grew with it,
so measured, every field in the purchase dialog sat 6.6px from the right
edge and 25px from the left. One crowded row was setting the padding for the
whole dialog.
`grid-cols-[minmax(0,1fr)]` lets the column shrink to the space actually
available, which puts the element's own `p-6` back in charge.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two patterns the app had been rebuilding per call site.
NativeSelect: eight `<select>` elements had each been hand-styled, arriving
at four different answers — `h-8 rounded border px-1 text-xs`, `h-9 rounded
border px-2 text-sm`, `h-9 flex-1 rounded ...`, and one correct `h-9
rounded-md border-input px-2`. Measured, that is two radii (4px against the
system's 6px), two border colours (the row hairline against the 3:1 field
border) and three text indents against Input's 12px. In the purchase dialog
a select sat directly beside the Total input in a visibly different control
language.
It stays a native select on purpose: the platform picker on a phone and
type-to-select on a keyboard are worth more here than a prettier menu, and
entry speed is a stated product principle. The chevron is a background image
from a new `--budget-select-chevron` token rather than a positioned icon,
because that needs no wrapper element — so `className` lands on the select
itself and every call site's existing `w-full`, `flex-1`, `h-8` and `w-40`
keeps behaving as it did. The token is declared per theme since a
background-image stroke cannot be `currentColor`.
Segmented: four screens had built the same view switch from two loose
buttons, the selected one filled and the other outlined, separated by a gap.
That reads as one primary action beside one unrelated button rather than as
two states of a single control. One track, no gap, `aria-pressed` per
segment.
Neither is wired up yet; the call sites follow.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two surfaces the browser draws for itself, both shipping a default that
belongs to no theme in this app.
`accent-color` is what tints a native radio or checkbox. Unset, the split
editor's "rounding" radio was platform blue — the one colour on screen
picked by neither theme. It now takes the brand teal in both.
`::selection` likewise: dragging over a figure gave the OS highlight. It now
uses the accent and its paired ink, so selecting text in the dark theme
stays readable.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
src/index.css already declared "one visible, consistent focus treatment",
but shadcn's primitives ship a `focus-visible:ring-[3px]` of their own, so
every button and input actually drew two: a soft translucent halo hugging
the border, then a hard 2px outline 2px further out.
The global rule is the one that stays, because it also covers the links and
bare table buttons that never go through a primitive. The rings come out of
button.tsx and input.tsx instead.
Its `border-radius: 2px` goes too. That was squaring the corners of every
control with no radius utility of its own — the rule-history triggers in
Settings measured `rad=0`, so a focused one drew a sharp rectangle. The
outline now follows each element's own radius.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
GBP joins the currencies the app comes with: £, two decimals, and a
committed fallback rate of 8.704735 DKK to the pound. build.ts now
fetches it too, so a build with a network bakes a live GBP rate and one
without still covers it — the coverage check derives from the seed table,
so it demanded GBP as soon as the table listed it.
The seed table alone would not have delivered this. Baked currencies and
rates seed a NEW dataset and nothing else, so an existing dataset would
have been left typing the code, name, symbol and decimals by hand. A
4 -> 5 migration adds sterling to a dataset that lacks it, with its rate.
Supplying a rate is the one thing the "rates never backstop a cleared
rate" rule permits here: it forbids re-supplying a number the owner
deleted, not introducing one for a currency they have never had. A GBP
the owner already defined, or a GBP rate they already hold, is left
exactly as it is — asserted, including the case where their definition is
"wrong" (zero decimals), because it is theirs.
This exposed a real latent bug. The 2 -> 3 step seeded the live
seed-currency table BY REFERENCE, so adding sterling there would have
retroactively changed what an old dataset received from a step it had
already been through — silently, and only for whoever had not migrated
yet. Every step now carries its own frozen copy, and a source-level guard
fails the build if a live default is imported again. Scoping that guard
took two goes: matching the whole file flagged the comment explaining the
rule, and then flagged the frozen local copy that is the fix.
Two test fixtures used GBP as their example of an UNSUPPORTED currency
and now use CHF, which no seed table lists.
Verified in a browser by writing a schemaVersion 4 dataset into IndexedDB
and reloading: the currency table gains "British pound / £ / 2" with
"manual · 2026-09-02", the post currency selector offers GBP, and the
income and post survive untouched. Note for the next person — a migration
applies in memory on load and is written back on the next mutation, so
reading IndexedDB straight after a reload still shows the OLD version.
That is recorded in docs/TODO.md, because it made this verification look
like a failure at first.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
pages.yml passes BUILD_VERSION: ${{ github.sha }}, so the colophon
shipped in 61e7093 was printing all forty characters of the sha. It now
shortens a long hex string to seven, the way git does.
Found in the deployed service worker, which embeds the same version
constant: o="61e7093c36f9a0111a5f1c8776aba4894c88ebc4".
The pattern requires at least one a-f, so a long run of digits that is
not a valid date can never be mistaken for a sha and truncated. Anything
that is not a timestamp or a sha is still printed exactly as the builder
gave it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
"Fetch rates now" failed on the deployed site with nothing but a CORS
error. api.frankfurter.app now answers every request with a 301 to
api.frankfurter.dev/v1, and a redirect response must itself carry
Access-Control-Allow-Origin for a browser to follow it cross-origin. That
301 carries no CORS headers at all, so the request died before the
redirect could be taken.
Measured rather than guessed, with an Origin header set:
api.frankfurter.app -> 301, no access-control-* headers
api.frankfurter.dev -> 200, access-control-allow-origin: *
The response shape is unchanged, so parseFxResponse needed no edit.
Nothing was wrong at build time, which is why this went unnoticed: a
server-side fetch follows the 301 freely, so the baked rates were always
correct. Only the browser path was broken.
Two changes, because a new default alone fixes only some datasets:
- DEFAULT_FX_API_URL points at .dev/v1, with a test that fails if the
redirecting host ever comes back.
- A 3 -> 4 migration drops settings.fxApiUrl when it holds exactly the
dead default, which is the case for anyone who edited that field. It is
DROPPED rather than rewritten, so the dataset follows whatever the
current default is and the next move of the service needs no second
migration for the same user; a URL the owner chose is left strictly
alone. No stored figure changes, so it cannot alter a number in the
budget.
Verified by clicking the button in a real browser: "Updated 2 rate(s)",
USD 6.456195 and EUR 7.474959 both stored with source "api" — and the USD
figure differs from the baked constant, so it is a genuine live fetch.
Two migration tests asserted the chain ends at 3; they now assert
SCHEMA_VERSION, since what they mean is "all the way through".
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Production notes at the foot of Settings: where this came from, what it
was built with, what it is set in, and what it stores where.
The schema version is real, read from the dataset rather than written
into the copy, because it is the number that matters when someone opens a
backup and wonders what will read it.
The build stamp is new plumbing, and small: build.ts already computed a
`version` for the service worker, so the app bundle now gets the same
value. Read through a try/catch accessor, never a `typeof process` guard
(AGENTS.md §2) — and genuinely absent in development, which is the normal
case and reads as "development" rather than as an error. A caller's own
BUILD_VERSION is printed as given; only build.ts's default timestamp is
translated, because "1788379707862" tells a reader nothing and
"2026-09-02" tells them what they wanted. ISO, not a locale format: this
string ends up in bug reports and should not vary by who reads it.
rel="noreferrer" on the link is deliberate rather than boilerplate. An
app whose whole position is that the data never leaves the browser should
not hand this page's URL to another origin on the way out.
No licence is claimed for the app: there is no LICENSE file in the
repository, and asserting one in the UI would be worse than saying
nothing. The font licences are committed beside the files, so those are
named.
Verified: both build paths inline the value and leave no bare process.env
reference in the bundle (BUILD_VERSION=v1.4.0-test appears literally, the
default appears as a timestamp), and the section was read in both themes.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
CI failed on the guard added in 0c3729b, which passed locally. Both were
right: docs/TODO.md sent readers to
`.superpowers/sdd/2026-09-02-versioned-allocation-rules/`, a tool-managed
ledger whose own .gitignore is `*`. It existed on the machine that wrote
the reference and has never been in the repository, so the guard saw a
real file locally and nothing at all in a fresh checkout.
Two fixes, because there were two faults:
- TODO.md now points at the four commit messages that actually record why
those surfaces work as they do — de128e3, 1cb91d3, e3023d0, f94e458 —
and says plainly that the fuller ledger was never committed. Three of
the four brief defects it described are in those messages.
- The guard now checks a path is git-TRACKED, not merely present. That is
the invariant it was always meant to enforce: a path only the author
can follow is exactly the reference a reader cannot. It degrades to a
filesystem check when git cannot answer, and `.superpowers/` joins
docs/superpowers/specs/ as a declared exception — both are named in the
docs precisely because they are not in the repository.
A fresh clone is now the standard the check measures against, so it means
the same thing on a laptop and in CI.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Moving the docs rewrote about thirty cross-references in one go. A
reference left pointing at nothing fails no build, fails no test, and
does not look wrong in review — it is found by the next person who
follows it. That is the same silence eventCapture.test.ts and
cssTokens.test.ts exist for.
Checks markdown links and backticked paths in AGENTS.md, CLAUDE.md,
GEMINI.md, README.md and docs/*.md. A path counts as resolved from either
the file naming it or the repository root, since both conventions are in
use and both are unambiguous to a reader.
Scoped to the docs a reader NAVIGATES. docs/specs/ and docs/plans/ are
excluded on purpose: they are dated records of a moment, and the plan
that names src/App.tsx is describing scaffolding it went on to delete.
Holding history to today's tree would force rewriting the past or
deleting the guard.
One declared exception, with its reason in the source: AGENTS.md §4 names
docs/superpowers/specs/ precisely because it must NOT exist — it is the
tooling default that has to be overridden, or the next spec silently
recreates the old tree.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
AGENTS.md now holds the instructions and CLAUDE.md and GEMINI.md are
pointers to it. Three files of instructions become three files that
disagree; one file every agent reads cannot.
The content is reorganised rather than rewritten, into: the invariants,
the guards that fail the build, the validation this codebase deliberately
does not perform, the environment, and where to read next. Every guard
now names the test that catches it, and the two rules with no test behind
them are called out as such, because "only review catches this" is
information the reader needs.
Restructuring surfaced one rule that was missing here entirely — sw.js
must stay unhashed and be served no-cache — which had only ever been
written down in ARCHITECTURE.md.
Dropped: the `bun init` boilerplate code samples. Every rule they carried
is still stated; the hello-world test and the three frontend snippets are
not.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
ARCHITECTURE, PRODUCT and TODO move out of the repository root, and
docs/superpowers/{specs,plans} flattens to docs/{specs,plans}:
"superpowers" names the tool that wrote those documents, not a kind of
document.
No content changes. This commit is the move, plus the roughly thirty
cross-references the move invalidated.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Asked for as a reset that includes the currencies and the seed
categories. Between an empty dataset and first-run state the owner chose
first-run: the three starter posts, the DKK/USD/EUR table, the baked
rates, this month at zero income, no purchases.
store.reset() calls the same createSeedDataset(currentMonth, seedRates)
that load() uses on a first run, through the same write queue. Seeding
stays in the store rather than moving into the UI so "reset" and "a
brand-new browser" cannot drift apart, and a reset issued while a
mutation is still in flight cannot be overtaken by it.
The confirm panel reuses the import flow's shape and, more importantly,
its order of operations: the JSON backup downloads BEFORE the
destructive write, and a backup that fails aborts the reset rather than
destroying the only copy of the user's data. The write is guarded, which
src/ui/storeWriteErrors.test.ts requires of every direct store call in
the UI.
Also gives both confirm panels and the error line the top margin they
never had — they butted straight against the button row.
Verified by clicking through in real Chrome and reading IndexedDB back:
3 seed posts, DKK/USD/EUR, both baked manual rates, 0 purchases, one
month at zero income. Whether headless Chrome wrote the backup file is
not observable here, so the backup is issued rather than confirmed
openable — a gap the import flow has always had too.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Sub-project 2 of 4 of the visual redesign. Spec in
docs/superpowers/specs/2026-09-02-month-view-design.md; the owner chose
each of the three directions from concrete alternatives.
Three problems, all found by looking at the rendered page rather than at
the code:
- The totals block was four peer figures with no relationship between
them, and the income *input* sat in the same visual register as three
numbers derived from it. Nothing answered PRODUCT.md's second question
at the month level: with Transport at -3,200 you had to read down the
Remaining column to find out. It now reads income -> allocation meter
-> spend, with the overspent-post count stated in both directions.
- The carry meter rendered 21px below its own row and 22px above the
next, so it read as a divider rather than as a property of a post. One
tbody per post now holds the figures AND their meter, which is both
what pulls it tight under its own row and the only way to hover the
pair as one block.
- Phone width was broken and nothing recorded it: the 5-column table
overflowed at 390px with Spent and Remaining off-screen, and every
figure wrapped its currency onto a second line. Below sm: each post is
now a two-line block, and the currency is stated once in the legend
because every figure in that table is base currency.
Meter geometry moves to src/ui/meterSegments.ts, pure and tested, with
<Meter> as the only thing that paints it. The allocation meter scales to
max(income, allocated) so over-allocating overflows instead of capping
at a full bar — over-budget is always allowed, so it has to be drawable.
Negatives clamp rather than invert: a segment of negative width puts the
gradient's stops out of order, which paints something arbitrary instead
of failing.
Rejected: one reflowing CSS grid carrying role="table". It deduplicates
the markup, but at 390px the layout genuinely is not a table any more,
and table roles would describe rows and columns to a screen reader that
no longer exist on screen.
Verified: 443 tests, tsc clean, build clean, and read in both themes at
1280px and 390px. Not verified, because it is not verifiable here —
whether it reads better than what it replaced.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two requests. The second is small; the first changed the money layer.
TYPING THE CURRENCY. The purchase total field now reads "30$" as thirty
dollars, "€25,50" as EUR 25.50, "250 kr" as DKK, "1.234,56 usd" as USD
1234.56, and sets the currency selector from what was typed. Entry speed is a
stated product feature and a second control per amount works against it.
The field is type="text", not type="number": a number input silently discards
anything non-numeric, so "30$" would never reach the parser. It keeps its own
text so a half-typed "30." stays as typed and only commits when it parses.
Separator handling is where parseMoneyInput earns its 26 tests. With both "."
and "," present the LAST is the decimal point, which covers "1.234,56" and
"1,234.56" without knowing a locale. With one, it groups thousands if exactly
three digits follow and is a decimal point otherwise — so "1,500" is fifteen
hundred and "1,5" is one and a half. Spaces group thousands only in
three-digit groups, because "30 40" is two amounts typed by mistake and
committing it as 3040 would be worse than rejecting it. An unrecognised
currency returns null rather than falling back: reading "30 GBP" as 30 DKK
would book money at the wrong currency.
USER-DEFINED CURRENCIES. `Currency` was a closed union and decimal places were
a module constant, so "add a currency" was a code change. Both now live in the
dataset: `Dataset.currencies` is the authority on which codes are valid, what
they are called, their symbol, and how many decimals they round to. Settings
grows a table to add, edit and remove them.
That forced the money layer open. `roundMoney` takes a DIGIT COUNT, not a
currency code, and every one of its ~29 call sites resolves digits from the
dataset via `digitsFor`. Making digits a required number rather than an
optional one with a default of 2 is the whole safety argument: every site
became a compile error the type checker enumerated for me, where a default
would have left each one silently correct for DKK/USD/EUR and silently wrong
for the first zero-decimal currency the owner adds.
Because every existing currency uses two decimals, a broken version of this
refactor would still pass all 356 previous tests. src/domain/currencyDigits.test.ts
is the oracle that cannot lie: a JPY-style zero-decimal currency driven through
roundMoney, both FX directions, both split modes, finance-plan division, the
fold, and the month view, asserting no figure has a fractional yen in it.
Guards that come with it, all tested:
- Codes are identity — they key the FX table and every stored Money — so they
are normalised (" jpy " -> "JPY") and cannot be edited, only added/removed.
- A currency in use cannot be removed, and the refusal names what refers to
it. The same reasoning as posts being archived rather than deleted: stored
Money carries the code, and dangling references would make the import
validator refuse the owner's own exported file. Removing an unused one takes
its exchange rate with it, leaving no orphan row.
- The import validator now checks the currency table itself — codes, duplicate
codes, integer decimals 0-4 — and validates every Money against it. That
boundary check replaces the compile-time safety the closed union provided.
- parseFxResponse takes the accepted codes instead of a global constant, so a
rate service returning dozens cannot put unreachable rows in the table.
Migration v2 -> v3 seeds the currency table. Behaviour-preserving by
construction: every currency that existed had two decimal places and the
seeded table records two, so not one stored amount can round differently than
it did before. An existing `currencies` field is kept rather than overwritten,
since a hand-edited file could already carry currencies the owner added.
Verified in the browser, not just in tests: JPY added through the real Settings
form with 0 decimals, and the five inputs above typed into the real purchase
field, each setting the right currency.
Two obsolete test helpers went away. plans.test.ts and actions.test.ts used to
monkey-patch the CURRENCY_DIGITS constant to prove the table was consulted;
digits are an argument now, so those tests just ask for zero decimals.
types.test.ts no longer asserts "the supported currencies are exactly DKK,
USD, EUR" — that stopped being a property of the code.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two defects behind "the new version is ready" not going away.
1. THE WORKER NEVER INSTALLED — in any environment, ever.
install did `cache.addAll([...PRECACHE, SHELL])`. PRECACHE is derived from
the build's own outputs, which already contain index.html, and SHELL is
index.html again. addAll() rejects the ENTIRE install on a duplicate
request:
InvalidStateError: Cache.addAll(): duplicate requests (.../index.html)
So install failed on every load, the worker went redundant, and nothing
ever activated. Registration itself succeeds and the failure is inside the
install event, so nothing surfaced it — which is why offline behaviour was
listed as "never verified" rather than "broken". All 11 precached URLs
return 200; the list was the only fault. Deduping now lives in
src/swPrecache.ts, at the point of use, so no future addition to either
list can reintroduce it.
Verified in a real browser for the first time: the worker installs,
activates, claims the page, and caches 11 entries. A rebuild is then
detected, the prompt appears, clicking Reload applies it, stale caches are
dropped and the prompt does not return.
2. THE DEV WORKER IGNORED THE LIFECYCLE — my own regression, from 53707b1.
That commit made the dev server serve an inert sw.js to silence a MIME
error. Inert included the lifecycle: no skipWaiting, no clients.claim, no
message handler. A worker like that can sit in `waiting` forever and can
never be applied, so the prompt returns on every load and the Reload button
cannot possibly clear it. This is the likeliest cause of the reported
symptom, since with defect 1 a production build could never have shown that
prompt at all. The dev worker still caches nothing — that would fight HMR —
but now honours install, activate and SKIP_WAITING.
3. applyUpdate RACED ITS OWN RELOAD — real, measured, but not reproducible on
demand, so it is hardening rather than a proven fix.
It posted SKIP_WAITING and called window.location.reload() on the next
line. Measured in Chrome: at that instant the new worker was still
`installed` and the controller had NOT changed — it changed 2 ms later. The
window is wider in practice because activate deletes every stale cache
before claiming clients. When the reload wins, the reloaded page still finds
registration.waiting and shows the prompt again. It now waits for
controllerchange, with a timeout backstop so a worker that ignores
SKIP_WAITING cannot leave the button dead. The sequencing is pure and
tested (src/ui/swUpdate.ts): listener attached before the message, reload
exactly once, unsubscribed after.
Tooling: scripts/screenshot.ts gains --profile (a fresh Chrome profile has no
registered worker, so nothing stateful across loads could be reproduced at all)
and --eval-after-file (observe a flow that ends in a navigation from the other
side of it).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The density the owner chose. Newest day first, weekday named because that is
what a person recognises, and order within a day preserved so a row just
entered stays where it was put.
Two cases the obvious implementation gets wrong, both tested:
A finance plan's later slices belong in this month — money moves this month —
but the purchase's own date does not. Filing them by date would have put
"14 August" inside the September list, so they group separately, last, under
"Financed from earlier months".
A purchase date may legally be "YYYY-MM" with no day at all, which gets its
own trailing group rather than a heading reading "NaN".
The weekday is computed in UTC on both ends. `new Date("2026-09-01")` parses
as UTC midnight, so `getDay()` anywhere west of Greenwich reports the previous
day and every heading would be wrong by one for the user. A test pins three
timezones.
Trade-off worth stating: headings cost vertical space on a day with a single
purchase, and only pay for themselves when a day has several. PRODUCT.md says
the owner records in batches — "a grocery trip is twenty lines entered in one
sitting" — so real data should sit on the paying side of that.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Sub-project 1 of the redesign. The diagnosis that shaped it: a design system
already existed and was never wired up. src/index.css defined --paper, --ink
and a muted teal with a contract document behind them, while the app painted
itself with shadcn's default white and near-black. Those tokens reached the
screen in exactly two places — the carry meter and the +/- number colours.
TOKENS. Two layers: --budget-* is the app's own vocabulary, defined once per
theme, and shadcn's whole variable set points at it, so every primitive
inherits both themes rather than being fought. Light is cool paper with ink
navy; dark is deep slate, never pure black, because a black ground makes the
hairlines and the carry meter float.
The brand teal is --primary, NOT --accent. That is precisely what the old bug
got wrong: shadcn's --accent is a subtle hover BACKGROUND paired with
--accent-foreground, so a saturated colour there turns every hover into a
coloured slab with unreadable text. src/cssPairs.test.ts now makes the rule
mechanical — redefining either half of a shadcn pair without the other fails
the build, in any block.
CONTRAST was computed, not eyeballed: the weakest text pair is 5.50:1 against
a 4.5:1 requirement, and field borders clear the 3:1 non-text requirement on
both of their backgrounds. Verified afterwards by reading the tokens back out
of the live browser in both themes rather than trusting the arithmetic.
TYPE. Inter and JetBrains Mono, self-hosted (SIL OFL, licences committed
alongside), latin + latin-ext only. Not in index.css: Bun's CSS bundler
resolves every url() from disk and inlines woff2 as base64 — relative or
absolute, and a "file" loader override does not stop it — which made the
stylesheet 303 KB of render-blocking bytes and defeated the unicode-range
subsetting entirely. They are injected at boot from one definition instead,
which is the only arrangement identical in dev and in a subpath build.
THEME TOGGLE cycles system / light / dark, remembers the choice in
localStorage (a per-device display preference does not belong in the exported
budget), keeps following the OS while set to "system", and is applied by a
tiny inline script before first paint so a dark-theme user never sees a white
flash. Every storage access is wrapped: localStorage throws outright in some
configurations, and a theme preference must never stop the app booting.
A REGRESSION I CAUSED AND CAUGHT: renaming --rule to --budget-rule silently
killed the carry meter. An undefined custom property inside linear-gradient()
does not warn, does not throw and does not fall back — the declaration is
invalid, so the element paints nothing. It looked like a missing feature. Only
pixel-sampling a screenshot found it. src/ui/cssTokens.test.ts now fails if any
TypeScript file references a token src/index.css does not define.
Also: one Section/Stat card treatment applied across month, year, summary and
settings; quiet uppercase table headers; row hover; the rule-editing and
purchase-row actions revealed on hover instead of six always-on links; a scroll
fade on the year matrix so it reads as "more to come" rather than clipped; and
"September 2026" instead of "2026-09" as the month heading.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Found by screenshotting it. 13 numeric columns of "28,000.00" in a mono face
cannot fit in min-w-[48rem], so the table shrank the cells instead of
scrolling and the figures ran together: the income row read
"28,000.0028,000.0028,000.00", the total read "0.000.00248,500.00", and
columns 11 and 12 were crushed to nothing. Every cell now has horizontal
padding and cannot wrap, and the table is wide enough that the container
scrolls rather than crushing.
Verified by screenshot at 1440px: every figure separated, columns aligned.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
CLAUDE.md, AGENTS.md, ARCHITECTURE.md and TODO.md all said no browser exists
in this environment, so appearance had never been verified by anyone. Chrome
is on PATH. Its own --screenshot flag is what fails: it fires on the load
event, before this app's async IndexedDB read resolves, and captures a blank
page — which is presumably how the claim took hold.
scripts/screenshot.ts drives CDP instead: waits past the load event, seeds
IndexedDB via --eval-file and reboots the app with --reload, clicks with
--click, applies shadcn's class-based dark palette with --dark, and reports
console errors and uncaught exceptions. A page that renders blank from a
thrown error looks identical to an empty one, so the console output is half
the value.
Two defects found by simply looking:
- The month table's numeric columns had no horizontal padding, so figures ran
into each other: "+7,175.00 DKK4,200.00 DKK". Now padded.
- Dev logged "The script has an unsupported MIME type ('text/html')" on every
boot, because registerServiceWorker asks for sw.js and the "/*" catch-all
answered with the HTML shell. Dev now serves an inert worker.
Confirmed working, first time seen: the carry meter (including red where a post
is overspent), the Task 5 rule-history editor, the Task 6 "change from here"
control, and baked FX rates showing as "manual - 2026-09-01" in Settings.
Recorded in the docs, with the limits stated: this container's fontconfig maps
every generic family to a monospace face, so screenshots need a FONTCONFIG_FILE
override or you will chase a font bug that does not exist; Chrome starts from
an empty profile each run; and no screenshot can judge how a design feels.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
TODO section 2, designed and approved, now built. A brand-new dataset arrives
with DKK/USD/EUR rates instead of erroring on the first EUR purchase.
build.ts fetches at build time and embeds the result via `define`. A network
failure does NOT fail the build — it bakes committed constants instead, and an
incomplete response is treated as a failure too, since one unconvertible
currency is the problem this solves. Fetched rates carry source "api" and the
BUILD date, which is why parseFxResponse now takes an explicit updatedAt; the
constants carry source "manual" and their own date, so Settings never claims a
hardcoded number came from a rate service. SKIP_FX_FETCH=1 and FX_API_URL are
there for offline, CI and reproducible builds.
They seed a NEW dataset and nothing else. They are NOT a fallback for a missing
rate: clearing a rate removes its row so MissingRateError surfaces, rather than
silently converting money at a number the user never chose. A test asserts a
cleared rate still throws, and loading an existing dataset never touches its
rates.
Domain purity forced the shape. createSeedDataset takes the rates as an
argument (and copies the rows, so shared constants cannot be edited out from
under the caller); the env read lives in the new src/store/bakedRates.ts behind
the try/catch accessor, because an unset BUN_PUBLIC_* leaves a bare process.env
in the browser bundle; and createSnapshotStore takes seed rates as a parameter
so it stays free of the ambient environment, with src/store/index.ts wiring it.
Verified all three build paths: live fetch bakes api-sourced rates with today's
date, an unreachable endpoint and SKIP_FX_FETCH both bake manual constants and
still build, and the payload is present in the bundle in each case. Verified at
runtime that a fresh install converts EUR 100 -> 747.50 DKK and USD 100 ->
644.95 DKK with the variable both set and unset.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
TODO recorded these as needing a human because binary assets cannot be
generated in the agent environment. That turned out to be wrong: Chrome is on
PATH here and rasterizes the SVG exactly, so scripts/make-icons.ts generates
them and the output is committed. Verified pixel-accurate against src/icon.svg
before trusting it (background #0f172a, bars #38bdf8, underline #e2e8f0,
transparent rounded corners).
The generator uses an explicit Bun.spawn with a timeout rather than a shell
template, because Chrome did not always exit on its own here.
Both sizes now appear in the manifest, get copied into dist, are served by the
dev server, and are listed in the service worker precache — along with
icon.svg and manifest.webmanifest, which were also absent from it because the
list is derived from bundler outputs and these are written separately. Without
that an installed app could not render its own icon offline.
src/icons.test.ts checks the committed files are genuine 8-bit RGBA images of
the right dimensions by inflating IDAT and asserting the exact pixel-data
length, so a truncated or stub file cannot pass. It cannot detect staleness
if src/icon.svg changes — re-run the generator.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
monthView folds from the start of the dataset, so it costs O(months) per
call. The summary sheet and every per-post sheet each called it once per
(post, month), making a full export O(posts x months^2). buildWorkbook now
takes one pass, indexes the figures by post, and hands that to the sheets.
TODO called this "fine at personal-budget scale". Measured against the old
code it is not, once history accumulates: 30 posts over 10 years went from
7983 ms to 135 ms (59x), and that work is synchronous on the main thread, so
it was a visible freeze on clicking Export. 10 posts x 2 years: 44 ms -> 2.5 ms.
content.xml is byte-for-byte identical on a fixture exercising every sheet
(versioned rules, an override, an archived post, a percent split, a fixed
split and a finance plan).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Each of these was recorded with its reasoning already settled; each is fixed
test-first, and two turned out to be worse than the note claimed.
EMPTY_FIGURES is frozen. figuresFor returns it by shared reference for every
(post, month) the fold has no entry for, so one consumer mutating its result
would have moved every other unknown row with it.
Split values now round by what they MEAN. A split's value is a percentage in
"percent" mode but MONEY in the purchase's currency in "fixed" mode; both were
rounded to a hardcoded 2 places. sliceTotal likewise rounded to DKK's minor
unit whatever currency the slices carried. Note this is narrower than TODO
claimed: round2 is still correct for Rule.percent, which is not money at all.
Neither bug is observable while every supported currency uses hundredths, so
the tests force CURRENCY_DIGITS for their duration.
confirmImport guards its write. A failed import was an unhandled rejection with
nothing shown; the dialog now reports it and keeps `pending` so the user can
retry. A source-level guard covers the whole UI, in the spirit of
eventCapture.test.ts.
The manifest has one definition. The dev placeholder did not merely hardcode
scope "/" — it was never served at all: the "/*" catch-all answered
/manifest.webmanifest with the HTML shell, as text/html, and /icon.svg too.
buildManifest moves to src/manifest.ts, the dev server generates both from the
base path, and the source file stays as a valueless resolution stub because
Bun.serve has no `external` option to skip the <link> href.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The natural moment to decide "15% from July" is while looking at July. The
control writes the same setRuleFrom action the history editor uses, so the two
surfaces cannot drift apart. A month where a version begins gets its own badge,
distinct from a one-month override, so a step in the numbers has a visible
cause; a month can carry both.
The editor is keyed on post and month so switching posts remounts it — without
that, React reuses the instance and useState keeps the previous post's values.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Each post's allocation over time, as rows of "from this month, this rule".
Adding a rule for a month that already has one replaces it, and the form says
so rather than looking like an append. An unbudgeted post says so explicitly,
since that is a different state from a rule of zero.
The Add button stays disabled until the month parses, because setRuleFrom sorts
with compareMonths, which throws on a malformed MonthId. Also retires the
"Standing rule" wording, which Task 1's model change left stale.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Rejects a missing rules array, a malformed or out-of-range start month, and two
versions sharing a month — which would make the effective rule ambiguous — at
the import boundary rather than letting the fold resolve something arbitrary.
Also rejects a rule of an unknown kind and a fixed rule in an unsupported
currency. The first falls through resolveRule's percentage branch and turns
every figure after it into NaN; the second throws MissingRateError deep inside
the balance fold. Both are the failure class this validation exists to stop.
The rule's VALUE stays unvalidated: over-budget is always allowed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
setRuleFrom replaces rather than appends for a month that already has a
version, keeping the effective rule unambiguous, and keeps the series sorted.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Previously only an empty posts array was tested for the "no standingRule"
path; a post that IS present but lacks the field was exercised only by an
ad-hoc script, not a committed test. Asserts rules === [] (never a version
wrapping undefined, which would later crash resolveRule/ruleAt) and that a
sibling post's rule is unaffected.
A pure shape change: standingRule becomes one version effective from the fold
start, which is where it already applied from. Asserted behaviour-preserving —
every post in every month resolves to the same allocation before and after, so
no balance moves.
Post.standingRule becomes Post.rules: RuleVersion[], resolved by ruleAt() as
the last version effective at or before a month. Per-month overrides still win
outright; a post with no applicable version allocates zero rather than
applying its earliest rule backwards.
allocationFor keeps its signature, so the fold, the views and the ODS export
are untouched. Settings gets a read-only rule summary for now; the history
editor follows.
Implements docs/superpowers/specs/2026-09-02-fast-entry-refinements-design.md.
Three changes shipped together because they share one tab order.
Bulk rows are description -> amount -> note, and nothing else is a tab stop.
The list always ends in exactly one blank row, appended as soon as the last
row is filled, so Tab flows into a new line with no special case and new rows
appear on touch too. The Add-line button is gone; the per-row remove button is
now a hover/focus x with tabIndex={-1}, and Backspace in an empty description
removes the row and lands on the previous row's note.
Purchases no longer demand a day. `date` accepts "2026-09" as well as
"2026-09-14"; entry defaults to the month, with an opt-in "set exact date"
link. No migration: monthOf() already parsed both shapes, verified before
choosing this over splitting the field into month + optional day, which would
have meant a schema migration in the browser against the user's only copy of
their data for an identical outcome.
Notes are an optional field on Purchase, in both entry forms and shown beside
the description in the month lists. Empty notes are omitted rather than stored
as "", so absent stays absent.
Also closes a pre-existing gap the spec surfaced: the JSON importer never
validated purchase.date, so a malformed date passed import and threw later
inside the balance fold. It is now rejected at the boundary, with out-of-range
months and days covered.
A row holding only a note is deliberately NOT blank: it asks for the missing
description and amount rather than being silently discarded.
Keyboard behaviour - tab traversal, Backspace-to-remove, focus after append -
cannot be verified in this environment and needs confirming in a browser.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A grocery trip is twenty lines, and opening the full dialog twenty times
is the thing this exists to avoid.
PurchaseDialog gains a One / Many toggle, shown only when adding — editing
an existing purchase edits exactly one, so the toggle is hidden there. In
Many mode the date, post and currency are set once at the top and each line
is just a description and an amount; Enter on an amount starts the next
line, so a receipt can be typed without leaving the keyboard. Blank trailing
rows are ignored rather than flagged.
The whole batch commits in a single mutate: one write, one queue entry, and
no way to half-commit a shopping trip.
Lines are deliberately simple. A line needing a split or a payment plan is
saved plain and then edited from the month view — building per-line split
and plan editors would have been most of the work for a case the grocery
run never hits, and would have slowed the common path.
Line logic lives in a pure module (src/ui/bulkEntry.ts) following the
purchaseForm.ts pattern, so it is testable with no DOM: 20 tests covering
blank-row handling, per-row validation messages, the running total, and
that N lines become N purchases landing wholly in the shared post. Each
line is built through the same toPurchase the single form uses, so there
is one definition of what a purchase is.
Validation mirrors validatePurchase in what it deliberately does NOT
reject: a negative amount is a refund, not an error.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The "Income this month" field could not be changed from 0. Typing zeros
appeared to work; typing any non-zero snapped the field back to a single 0.
mutate() defers its callback behind the store's serialization queue and an
IndexedDB write. React re-renders and resets a controlled input's DOM value
back to the last committed value well before that callback runs, so a handler
reading event.target.value INSIDE the callback read the reset value and
committed 0 — forever. Committing 0 matches what is displayed, which is why
typing zeros looked fine.
Six handlers had this shape (income, post name, post currency, standing-rule
kind, base currency, rate service URL). Each now reads the DOM value
synchronously and passes it in. Sites that already captured into a const first
— the FX rate and the standing-rule amount — were never affected, which is why
those fields worked.
Purchase, split and plan editors were never affected either: they write to
local component state synchronously, never through the deferred queue.
Adds a source-level regression guard, because the trigger is real-browser
render timing that happy-dom does not reproduce.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Bun inlines a literal `process.env.FOO` reference only when FOO is set. With
it unset — a bare `bun --hot src/index.ts`, which is what the README tells you
to run — the reference survived into the client bundle and the app died on
load with `ReferenceError: process is not defined`, before rendering anything.
Production builds were unaffected, because build.ts passes an explicit define.
Reading the value now goes through readBasePathEnv(), which catches. Catching
rather than a `typeof process` guard is deliberate: after inlining the literal
becomes a string, but a typeof test would still run in the browser and discard
that inlined value, silently breaking every subpath deploy. Verified all four
paths: dev unset (reference survives, caught, base "/"), dev set (inlined),
and root + subpath production builds (inlined by define).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Fixes the three findings from the final whole-branch review:
- SnapshotStore.mutate/replace now serialize behind a promise queue, so
concurrent mutate() calls (e.g. two independent onChange handlers firing
before either write resolves) each clone from the previous commit instead
of racing on a stale snapshot and silently losing an edit. Preserves
write-before-move-snapshot, caller-visible rejections, structuredClone
identity, and non-wedging on a failed write. Adds tests for concurrent
mutations and a failing write mid-queue.
- DataSection's exportJson/exportOds are wrapped in try/catch and report
failures through the existing import error state/rendering, since a throw
(e.g. MissingRateError from buildOds) inside a plain onClick is invisible
to the error boundary. The pre-import JSON backup now explicitly aborts
the destructive replace if the backup export fails, rather than relying on
an uncaught throw to prevent it.
- PlanEditor no longer calls monthOf()/withPlan() with an empty or partial
date from a native <input type="date">, and requires an integer month
count (with step={1}) before calling withPlan/equalSlices, preventing
both domain throws from firing inside plain event handlers.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A purchase in a currency with no configured exchange rate makes
src/domain/fx.ts throw MissingRateError (correct behavior — never silently
convert at a wrong rate). Nothing caught it: 5 of 6 routes call
monthView/yearView/summaryView and threw during render, and with no error
boundary anywhere in src/, the throw unmounted the entire React tree
including the nav bar. Since the catch-all route redirects to the current
month on first load, one un-rated foreign-currency purchase white-screened
the app immediately on landing, with the only way out (Settings, where the
rate is added) unreachable because the nav was gone too.
Adds RouteErrorBoundary (a class component, as required for
getDerivedStateFromError/componentDidCatch) that renders the thrown
message plus a link to Settings and a "Try again" reset, and
RouteErrorBoundaryReset, a wrapper that keys the boundary on the current
route so it clears on navigation. Wraps only <Routes> in App.tsx, not
<nav>, so navigation stays usable when a route throws.
Also guards the happy-dom per-file GlobalRegistrator.register() call in
registerSw.test.ts and the new ErrorBoundary.test.tsx with
isRegistered, since Bun can run both files in one process.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Relative hrefs in the built index.html ("manifest.webmanifest", "icon.svg")
resolve against the *document* URL, not the site root. Since this is an
SPA where every route serves the same shell, a route with more path
segments than the base path (e.g. /budget2.0/month/2026-09, which is
where the app's own catch-all route redirects on first load) resolved
these to a wrong, 404ing path — breaking the manifest and favicon, and
with them installability, on the app's default landing URL.
build.ts now rewrites both hrefs to be absolute (prefixed with basePath)
as part of the existing dist/index.html -> dist/404.html step, keeping
the two files byte-identical. src/index.html itself keeps the plain
relative hrefs unchanged, since the substitution only happens in the
built dist/ output.
Also fixes a pre-existing, previously-uncaught bug surfaced while
verifying the dev server: bun --hot src/index.ts 500'd on every request
because Bun's dev-server HTML bundler (unlike Bun.build) has no
equivalent of the `external` build option and always tries to resolve
<link> hrefs as module imports — manifest.webmanifest never existed as a
real file under src/, only as build.ts's generated dist/ output. Added
src/manifest.webmanifest as a small static placeholder so the dev server
can resolve it; the production build still ignores this file entirely
(external: ["*.webmanifest"] in build.ts) and writes its own generated
manifest, unaffected by this addition.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>