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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Rate fetching is optional and fails soft: any network or parse error
leaves cached rates untouched and shows a message, never throwing into
render. A cleared or non-positive FX rate input now calls the new
removeFxRate action instead of storing 0, since toBase() silently
converts a stored 0 to a 0-value conversion while an absent row
correctly throws MissingRateError. Posts are archived, never deleted.
Import downloads a backup of the current data before replacing it and
requires confirmation showing before/after counts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PostMonthRoute's "Already committed" list filtered future schedule slices
only by month, never consulting cancelledFromMonth, so a cancelled plan's
remaining slices kept appearing as future obligations the fold would never
actually charge. Reuse sliceAmountForMonth's existing cancellation rule
instead of re-deriving it inline, to avoid a second copy of the >= boundary
drifting from the domain's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Future finance-plan slices are surfaced as already-committed so a plan is
visible before it lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Validation lives in purchaseForm.ts as pure functions. Unbalanced splits
and over-budget purchases are deliberately not errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Negative remaining balances are shown in a warning treatment; nothing
blocks going over budget.
Applies the Tasks 18-23 design contract: --paper/--ink/--rule/--accent/
--surplus/--overspend tokens and the three type-role font variables in
src/index.css, --font-money on every money figure, and the carry meter
(a 3px hard-stopped gradient beneath each PostTable row) encoding
carried-in surplus, allocation track, spend fill and overrun.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base path comes from BUN_PUBLIC_BASE_PATH so the same bundle serves both
a root Docker deploy and a GitHub Pages subpath.
Deviation from the task-16 brief: does not install @happy-dom/global-registrator,
does not create test/setup.ts, and does not add a [test] preload to bunfig.toml.
A global DOM preload would make src/domain/smoke.test.ts's "importable without a
DOM" assertion vacuously true, silently retiring the check that guards the
domain-layer-stays-pure invariant. Nothing in this task needs a DOM: basePath.test.ts
is pure string logic, and the shell render test was intentionally omitted by the
brief itself. A later task will register happy-dom per-file where a DOM is
genuinely required.