100 Commits
Author SHA1 Message Date
Lucas Winther 213b138bce fix: keep pie chart colors stable across selections
Continuous Integration / lint-and-test (push) Successful in 17s
Continuous Integration / docker-build-and-push (push) Successful in 34s
2026-10-03 20:15:58 +02:00
Lucas Winther 2bd5c754e8 feat: filter spending posts and collapse month sections
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 34s
2026-10-03 17:01:53 +02:00
Lucas Winther 5c6cee0f0b docs: refresh current behavior and outstanding work
Continuous Integration / lint-and-test (push) Successful in 18s
Continuous Integration / docker-build-and-push (push) Successful in 1m11s
2026-10-03 16:50:58 +02:00
Lucas Winther c250bbe476 feat: refine month totals and recurring purchase entry
Continuous Integration / lint-and-test (push) Successful in 29s
Continuous Integration / docker-build-and-push (push) Successful in 40s
2026-10-03 16:40:00 +02:00
Lucas Winther 7cccd7a41a feat: add month breakdown and session backups
Continuous Integration / lint-and-test (push) Successful in 17s
Continuous Integration / docker-build-and-push (push) Successful in 35s
Allow recurring costs and confirmations to remain unassigned when created from a purchase without a post. Add browser backup retention and restore controls, with coverage and documentation updates.
2026-10-01 04:01:49 +02:00
Lucas WintherandClaude Opus 5.5 019517e6e9 docs(todo): what the anchored-model merge left unfinished
Continuous Integration / lint-and-test (push) Successful in 14s
Continuous Integration / docker-build-and-push (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 16:53:22 +02:00
Lucas WintherandClaude Opus 5.5 933027cb1b Merge main into the anchored model
Main moved on by 27 commits since this branch forked, including three schema
steps of its own (budget folders, placement, nested bills). Resolutions worth
knowing:

- The anchored-model migration becomes 10 -> 11; main's 7 -> 10 run first.
- Main's nextUnconfirmedOccurrenceOnOrAfter walked the calendar grid for
  every cost, which would have shown the slot-model "next due" for an
  anchored bill in the budget tree. Both walks are now generators and the
  lookup dispatches on anchoring, like occurrencesOf.
- A folder's "Bills due" summed only occurrences, and an anchored bill's
  charges are never occurrences, so a charged phone plan read as 0 due.
  budgetTree now adds an anchored bill's charges in the month.
- Main's RecurringSection (with RecurringSplitEditor) supersedes this
  branch's split editor; the anchoring rules are ported onto it: start-date
  validation via startDateNeedsDay, month precision hidden for lastCharge,
  the anchoring control locked once a cost has charges.
- PurchaseDialog keeps main's placement control. The "Recurring cost" select
  replaces main's "Unlink from bill" checkbox, since "None" does the same.
  A bill created from the dialog nests under its post like one from Settings.
- Dropped main's "bounded projection ignores invalid confirmations" test: it
  exercised slot confirmations on a lastCharge cost, which no longer exist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 16:51:17 +02:00
Lucas WintherandClaude Opus 5.5 1ae3f9720b fix(store): an explicit source: undefined clears a purchase's tag before validation
updatePurchase resolved the pair it validates with `??`, which cannot tell
"no source key" from "source: undefined, clear the association". Untagging an
anchored charge while dropping it to a month-only date was refused, citing the
cost being removed. Resolve by key presence instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 16:42:46 +02:00
Lucas Winther dfbe584874 fix: keep unassigned purchases visible in budget
Continuous Integration / lint-and-test (push) Successful in 12s
Continuous Integration / docker-build-and-push (push) Successful in 32s
2026-09-30 16:35:44 +02:00
Lucas Winther f02c452d6c fix: nest legacy bills and stamp builds with commit
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 35s
2026-09-30 14:35:29 +02:00
Lucas Winther 85a21c58eb feat: place purchases and bills in budget hierarchy 2026-09-30 13:48:49 +02:00
Lucas Winther b0e8145d2e feat: support unassigned purchases and allocation status
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 35s
Allow month-only purchases to start finance plans. Keep unbudgeted posts distinct from explicit zero rules in overspend reporting.
2026-09-30 13:10:10 +02:00
Lucas Winther c1446db39f feat: improve recurring cost setup and folder management
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 33s
2026-09-30 01:50:48 +02:00
Lucas Winther 804f32c5fb feat: add session undo and redo
Continuous Integration / lint-and-test (push) Successful in 14s
Continuous Integration / docker-build-and-push (push) Successful in 34s
2026-09-30 01:33:29 +02:00
Lucas Winther d38584396b docs: record final folder test count
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 33s
2026-09-30 01:05:59 +02:00
Lucas Winther 581ede9b07 fix: preserve blank-name backups and open linked bill editors 2026-09-30 01:04:08 +02:00
Lucas Winther fc14341f1e docs: update folder worktree state and follow-up queue 2026-09-30 00:57:50 +02:00
Lucas Winther 228d48d978 fix: clarify currencies in mixed budget legend 2026-09-30 00:54:15 +02:00
Lucas Winther abf57a963f docs: explain budget folder placement and totals 2026-09-30 00:50:42 +02:00
Lucas Winther d3c14857fc feat: show budget folders on month page 2026-09-30 00:50:42 +02:00
Lucas Winther e715160235 fix: preserve folder rename drafts until save 2026-09-30 00:40:21 +02:00
Lucas Winther 725c836788 feat: organize posts and bills in settings 2026-09-30 00:35:45 +02:00
Lucas Winther 6de115843b feat: choose recurring bill destinations 2026-09-29 20:49:18 +02:00
Lucas Winther 1e30671d76 feat: derive folder bill totals and mixed tree 2026-09-29 20:41:38 +02:00
Lucas Winther 390abf2be4 fix: keep budget sibling order distinct at numeric limit 2026-09-29 20:36:33 +02:00
Lucas Winther 3f976e0943 feat: manage nested budget folders 2026-09-29 20:34:16 +02:00
Lucas Winther 046ca7824e fix: reject blank imported budget item names 2026-09-29 20:29:26 +02:00
Lucas Winther c34aa846b9 feat: add budget folder schema and migration 2026-09-29 20:25:47 +02:00
Lucas Winther 67c77eb247 docs: plan budget folders and recurring cost placement 2026-09-29 20:18:25 +02:00
Lucas Winther f1e42d9d77 docs: design budget folders and recurring cost placement 2026-09-29 20:13:45 +02:00
Lucas Winther 4905986292 ci: smoke-test image without binding a host port
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 37s
2026-09-29 19:59:20 +02:00
Lucas Winther eea868c422 ci: separate Gitea publishing from GitHub workflows
Continuous Integration / lint-and-test (push) Successful in 14s
Continuous Integration / docker-build-and-push (push) Failing after 26s
2026-09-29 19:56:29 +02:00
Lucas Winther ef5dfc1e6b ci: publish Docker image to Gitea registry
Continuous Integration / lint-and-test (push) Successful in 13s
Deploy to GitHub Pages / build (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Failing after 9s
Deploy to GitHub Pages / deploy (push) Failing after 3s
2026-09-29 19:52:39 +02:00
Lucas Winther aed2313167 fix(ui): harden summary and settings workflows 2026-09-29 19:41:58 +02:00
Lucas Winther c93ff9908c fix(rules): review and harden rule-history editing 2026-09-29 19:01:57 +02:00
Lucas WintherandClaude Opus 5 7f6cf4692a docs: the day-granularity guard, and two known holes in the recurring model
AGENTS.md §2 gains the guard this fix wave installed, stated as covering BOTH
an anchored cost's `startDate` and its charges' own dates — checking only the
first is what let the second through — and naming the one place the rule now
lives so a fifth copy does not appear.

Two items for docs/TODO.md, neither of them a defect this branch introduced:

- **Editing a live calendar cost re-grids it.** `updateRecurringCost` refuses
  an `anchoring` change on a charged cost but accepts `recurrence` and
  `startDate`, which for a calendar cost ARE the grid — so every charge's
  stored `occurrenceDate` can be orphaned and the paid bill counts twice.
  Reproduced and measured: a 7-day cost with its second slot settled goes from
  `expected 4000 / projected 0` to `expected 6000 / projected -2000` when `n`
  becomes 5. Silent money wrongness, and the honest fix is a product decision
  about what editing a live bill means, so the behaviour is left alone.
- **An anchored cost has no history of expectations.** Recording a charge
  erases every earlier month's expectation, so `overdue` collapses to 0 and a
  past month's `projected` moves (`remaining` does not). It follows from the
  spec, but it sits in tension with §1's "archiving must not retroactively
  remove past expected charges" and deserves saying out loud. Measured
  figures included.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:32:31 +02:00
Lucas WintherandClaude Opus 5 0095507522 refactor(month): recordedDateFor's anchoring parameter was dead
Both call sites passed the literal "calendar", and the second is already
guarded by `occurrence.confirmable` — which IS `cost.anchoring === "calendar"`.
So the `: null` arm was unreachable and the same decision was expressed twice.
Whether an anchored row gets a Confirm button at all is decided once, in
`ComingUpRow.confirmable`; this function only answers which date a click
records.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:32:31 +02:00
Lucas WintherandClaude Opus 5 3a4086807a fix(purchase): do not offer a tag the write will refuse
`offerableCosts` offered every non-archived anchored cost whatever the date, so
the default new-purchase flow — month-only, since `emptyDraft` seeds
`date: monthId` — offered the owner a choice that now fails at the boundary.
An anchored cost needs a day, the same way `newCostOptionIsSelectable` already
gates the new-cost pairings. A calendar cost is unaffected: its charge names
the slot it settles and its own date is never stepped from.

An anchored cost stays SELECTED when the date drops to month-only, because a
selection is never taken away out from under the owner — so the dialog says
what is wrong there rather than letting Save be the first place it comes up.

Two more in the same file and its neighbour:

- A recurring cost that has vanished since the dialog opened (deleted from
  Settings in another tab) made the purchase save with no association and no
  message, leaving the bill still showing as expected. It is refused now.
- The comment above `save()` described `requireConfirmationAdvances`, deleted
  in this branch. A write can still be refused; only the stated reason was
  stale.

`normalizedStartDateFor` and `isValidStartDate` both asked their own copy of
the granularity rule, and the first had `anchoring` optional — which the unit
handler omitted, silently answering "calendar" for a cost anchored to its last
charge. Both call the domain predicate now, with anchoring required.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:32:17 +02:00
Lucas WintherandClaude Opus 5 19087737eb fix(validation): both boundaries reject a month-only date on an anchored charge
Every guard this model shipped with checked the COST's `startDate`
granularity. Nobody checked the CHARGE's. An anchored (`lastCharge`) cost has
no slots, so `anchoredOccurrences` takes the latest charge's own
`Purchase.date` as its anchor and steps forward from it — and `Purchase.date`'s
documented shape allows "YYYY-MM". Both write paths accepted that and the next
fold took every money route down, with no way to recover from the UI: the
anchoring select is disabled on a charged cost, there is no delete action, and
the walk's precheck sits before the `endedFrom` break, so ending the cost does
not help either.

It was reachable by default, not by accident: `emptyDraft` seeds
`date: monthId`, so a new purchase starts month-only, and the dialog offered
every anchored cost regardless. `updatePurchase` accepted the same shape when
"use the month only" truncated the date of an existing anchored charge.

`requireSourcePairing` now takes the RESOLVED date, so an update touching only
the date is checked against the source already stored. Both boundaries also
route a day-shaped value through the domain's own parser, the way each already
does for `startDate`: "2026-02-30" is day-granular and calendar-impossible, and
`PURCHASE_DATE` lets it through, so it would otherwise import clean and throw
from the same frame.

Both files now ask `startDateNeedsDay`/`chargeDateNeedsDay` rather than
restating the rule, and `parseDatasetJson`'s doc no longer claims more than it
checks.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:32:05 +02:00
Lucas WintherandClaude Opus 5 bdb2b9d80f fix(store): 7 -> 8 expands a month-only charge date on an anchored cost
The step stripped `occurrenceDate` from an anchored cost's charges and left
`Purchase.date` alone. Under schema 7 those dates are legitimately month-only:
a monthly cost's grid was month-granular, and `confirmOccurrence` wrote
`date: overrides.date ?? occurrenceDate`, so every one-click confirmation of a
month-granular anchored bill was stored without a day. An anchored cost has no
slots, so after the upgrade that date IS the anchor the series steps from — a
realistic schema-7 dataset therefore migrated cleanly and then threw out of
every money route on first load. The owner has a phone bill; this is their
data, not a hand-edited file.

The day comes from the `occurrenceDate` about to be stripped when that slot
falls in the same month, because it is the day the owner actually settled;
otherwise the 1st, the same convention the `startDate` expansion already uses.
A slot in a DIFFERENT month is not borrowed from — that would move the
purchase, and moving money is what this step must not do.

Extending an unshipped step rather than appending a new one: 7 -> 8 exists only
on this branch, so no stored dataset has been through it.

The regression test builds a schema-7 dataset the app itself would have
written and folds the result, rather than only inspecting the shape. A second
test pins the agreement the two halves of this fix have to keep:
`parseDatasetJson` migrates before it validates, so everything this step
produces must satisfy the import boundary the next commit tightens.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:31:52 +02:00
Lucas WintherandClaude Opus 5 d542a6bdab fix(domain): the anchored walk is day-precise under every recurrence kind
The precheck in `anchoredOccurrences` covered `everyNMonths` only, so a
month-only starting point on an `everyNDays` or `everyNWeeks` anchored cost
fell straight through to `addDays` and threw a bare "Not a day-granular
IsoDate", naming no cost, several frames below `foldBalances` — the exact
failure the precheck exists to prevent, for two thirds of the costs that can
have it. The walk is day-precise under all three kinds, so the check is too.

The rule about WHICH dates need a day was written out separately in four
places (both write boundaries and both controls that offer the pairings). It
now lives once, in `granularity.ts`, alongside the second, different rule the
next commits need: an anchored cost has no slots, so its latest CHARGE's own
`Purchase.date` is the anchor the series steps from, whatever the kind.

Three comments corrected to describe the code that exists:

- `stepFrom` and `anchoredOccurrences` both claimed neither write boundary
  rejects a month-only `startDate`. Both do, and have since this model landed.
  Pointing the reader at the closed hole is why the open one — the CHARGE's
  own date — survived four reviews.
- `Anchoring`'s doc still said confirming the occurrence early is the whole
  mechanism. An anchored cost has no occurrence to confirm; recording a charge
  is the mechanism.
- `Occurrence.date` described slot identity as universal (it is calendar-only),
  and `confirmedBy` did not say it is unconditionally null on the anchored path.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 18:31:40 +02:00
Lucas WintherandClaude Opus 5 49811f6795 docs(recurring): two schedules, and why they must not be unified
AGENTS.md §1's old invariant was half wrong the moment anchored costs lost
their slots: occurrenceDate is identity for calendar only. The replacement says
what each schedule is and, more usefully, warns against collapsing them —
that collapse is exactly the bug where paying twice in September pushed the
next charge to November.

§2's termination bullet now describes a defensive assert rather than a live
guard, because the anchored walk steps forward from a real date and the grid
walk never rebases. Also added the anchoring-change-refused-once-charged
guard, which was undocumented in AGENTS.md despite having its own test suite
and UI-level guard.

DECISIONS.md, PRODUCT.md, ARCHITECTURE.md and TODO.md catch up to what
shipped: two projection models and the three guards it deleted, a recurring
cost charging more than once per cycle and being creatable from the purchase
dialog, the anchored walk beside the grid walk, and schema 8 at 730 tests.
The 2026-09-03 spec gets a header note pointing at the new spec and nothing
else — it stays an accurate record of what was decided then.

Demo data seeds two charges in one month on an anchored cost (Car insurance),
so the fixed behaviour is the first thing a screenshot shows.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-06 00:05:36 +02:00
Lucas WintherandClaude Opus 5 35caa123cc fix(purchase): stop offerableCosts crashing render on an empty date
Finding I3 (Critical): PurchaseDialog calls offerableCosts(dataset, draft.date)
unconditionally in its render body. The native <input type="date"> lets the
owner clear it, setting draft.date to "" — monthOf("") throws, uncaught,
during render. Unlike an event-handler throw an error boundary can react to,
this crashes the dialog with no interaction to undo.

monthOf itself stays strict per AGENTS.md (no softening a domain throw into a
fallback): offerableCosts now wraps it in a UI-layer isParseableMonth check
and returns no costs for an empty (or otherwise unparseable) date, rather than
reporting an error for a form the owner simply hasn't finished.

Checked the other recurringForm.ts helpers that take a date for the same
hole:
- newCostOptionIsSelectable had it too — "everyNMonths"+"calendar" read as
  selectable for ANY date length, including empty, though seedCostFromDraft
  still hands that combo's date to monthOf. Fixed the same way, so every
  "+ New recurring cost..." option is disabled while the date is empty.
- sourceFor and seedCostFromDraft are only ever called at save time, inside
  the guarded store.mutate, never from render — left strict, since a bad
  date there already surfaces as a caught save-time error exactly like any
  other bad-date save (ensureMonth's monthOf in addPurchase). Documented and
  tested that determination rather than inventing a second rule for it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-05 23:55:54 +02:00
Lucas WintherandClaude Opus 5 04a089eb06 fix(purchase): disable recurring options a month-only date would reject
Review finding I2: choosing "+ New recurring cost..." with unit "days" (or
"months" anchored to the last charge) while the purchase's date is month-only
threw a store-level error naming a field ("startDate") the purchase dialog
never shows, with no mention of the "set exact date" toggle that fixes it.

Per the controller ruling, this stops OFFERING the action instead of
rewording the throw: newCostOptionIsSelectable (src/ui/recurringForm.ts)
mirrors the store's own needsDay rule and drives `disabled` on each unit and
anchoring <option>, with a hint pointing at "set exact date". Switching to
month-only also resets a stranded selection (e.g. "days") back to the one
pairing that always works, so a previously valid choice never sits selected
in a now-disabled state.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-05 23:44:37 +02:00
Lucas WintherandClaude Opus 5 87f309a32c feat(purchase): tag a purchase to a recurring cost, or create one from it
Two requests in one control. Tagging an existing cost is the refresh: the data
runs out, the owner enters the purchase, picks the plan, and the money is
booked while the cycle restarts from that date — because the anchor is the
latest charge. The data cap stops needing a flow of its own.

"+ New recurring cost" seeds a cost from the purchase being typed, and that
purchase becomes its first charge, so an anchored cost created this way is
anchored from the moment it exists. Both land in one mutate call, so a failure
cannot leave a cost with no first charge.

A calendar cost is offered only where its grid has a slot in the purchase's
month. sourceFor resolves that slot by LOOKUP (walking occurrencesOf and
matching the purchase's month) rather than by deriving it from monthOf alone —
everyNDays + calendar is a legal combination whose slots are specific days, so
a derived slot could name one that does not exist on the grid.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-05 23:32:38 +02:00
Lucas Winther ccde30b8ea feat(settings): a recurring cost's splits are editable
The owner's first report: a cost's post was fixed at creation and could not be
changed. RecurringCost has carried splits and splitMode with the same shape as
Purchase since the first version, so this wires up a capability the data model
already had rather than adding one — and a bill can now span envelopes.

SplitEditor is narrowed from PurchaseDraft to the four fields it actually
reads. PurchaseDraft satisfies that structurally, so the dialog's call site
does not change at all.

The anchoring control is disabled once a cost has charges, matching the store's
refusal rather than offering an action that would fail on save.

Also fixes a carried item: the anchoring handler now expands a month-only
startDate in the same mutate call that switches to lastCharge anchoring,
matching the normalisation the recurrence-kind handler already had — an
everyNMonths cost anchored to lastCharge needs a day-granular startDate, and
the store validates that pairing on every write.
2026-09-05 23:18:53 +02:00
Lucas WintherandClaude Opus 5 61e3773733 feat(month): an anchored cost's future row is informational
This month's row keeps its one-click Confirm for both anchorings — for an
anchored cost that records "paid on time" at the due date, and paying a bill
on schedule is the common case that must not get slower.

A future row is different. Only a calendar cost can be paid early in a way a
button can express, because its grid does not move. Confirming a future
ANCHORED charge is the refresh, and a refresh needs a real date and possibly a
different amount — the purchase dialog's job. A button here would have to
invent a date, and inventing it is what produced the round-1 Critical.

Also rewrites expectedGroups' docstring, which still described the deleted
X1/wouldAdvancePast rule as current behaviour, and drops a stray "X1" label
left over a defensive null-guard it no longer has anything to do with.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-05 23:08:10 +02:00
Lucas Winther 7a755f6406 fix(validation): review round 1 — close the store's source hole, fix ordering and messages
I1: a calendar charge's MISSING occurrenceDate no longer reports the literal
word "undefined" (String(undefined) printed it, and that text appears
nowhere in the file someone is searching). Missing and malformed are split
into two messages; the missing one names the cost, matching the anchored
side's message.

I2: addPurchase/updatePurchase accepted an arbitrary `source` unchecked —
only confirmOccurrence built a coherent one. A later task hand-builds a
source and passes it straight to addPurchase, so this closes it at the store
layer now rather than leaving that task to carry the validation. Both
enforce the same pairing the importer does: a calendar cost's source
requires occurrenceDate, an anchored cost's source forbids it, an unknown
cost throws. updatePurchase checks the resolved pair (incoming source if
given, else the stored one), the same style already used for anchoring.

M1: the "no recurring costs" fold test added last round was a duplicate of
an existing one and couldn't fail independently. Replaced with a test that
actually discriminates which charge an anchored cost's walk steps from —
a 10-day cycle matching the gap between three charges, where anchoring off
the earliest instead of the latest changes whether anything is still
expected in the same month. Verified by making that swap in
occurrences.ts, watching it fail, and reverting.

M2: updateRecurringCost now refuses a charged cost's anchoring change
before checking startDate granularity, so a doomed request fails on the
real reason first rather than making the owner fix an unrelated date
problem and resubmit into the same wall.

Also closed two pre-existing asymmetries with json.ts: requireRecurrence
now rejects an unknown recurrence kind, and both addRecurringCost and
updateRecurringCost reject an unknown anchoring — both previously relied
on TypeScript's unions alone, which a hand-edited object or an `as` cast
does not go through.
2026-09-05 22:58:20 +02:00
Lucas Winther 77bd049fb9 test(domain): fold coverage for the anchored model, plus a startDate doc fix
fold.test.ts had no case exercising foldBalances/figuresFor against a
lastCharge cost at all. Adds: three same-cycle charges landing in one month
count fully as ACTUAL charges with nothing left EXPECTED in that month (an
anchored occurrence is only ever a future projection, never a settled slot);
and a no-recurring-costs dataset keeps projected == remaining, pinned
separately from the calendar-shaped fixture already covering that shape.

RecurringCost.startDate's doc comment still said "YYYY-MM for everyNMonths,
YYYY-MM-DD otherwise" — no longer true now that an anchored everyNMonths
cost needs a real day too. Rewritten to name the actual rule and point at
where both boundaries enforce it.
2026-09-05 22:42:35 +02:00
Lucas Winther 8d8407becf feat(validation): occurrenceDate is required by calendar, forbidden by anchored
Both boundaries enforce both directions, because a cost's anchoring now
determines what a charge against it may carry. A slot id on an anchored cost
describes a schedule the app will not compute.

startDate granularity widens: anchored monthly costs now need a real day,
since their next due date is computed day-precisely from a charge date.

Changing anchoring on a cost that already has charges is refused. Stripping
slot ids one way is mechanical; inventing them the other way is not, and a
guess would leave real money settling nothing.
2026-09-05 22:42:28 +02:00
Lucas Winther 6d63aafab4 feat(store): migrate to the anchored model, schema 8
Strips occurrenceDate from an anchored cost's charges, because that path has
no slots and a leftover slot id is a lie a future reader would trust. Expands
a month-only startDate on an anchored monthly cost to the 1st, because day
precision now needs a real day. calendar costs are not touched at all.

Deliberately NOT behaviour-preserving — an anchored cost's projected dates
change, which is the point. Every recorded figure is preserved: no Money is
touched, no purchase added or removed.
2026-09-05 22:32:12 +02:00
Lucas Winther c49224d4f7 fix(domain): review round 1 — name the cost, cover the anchored throw, pin two reachable-but-untested paths
C1 (Critical): a month-only startDate on an anchored everyNMonths cost with
no charges yet reached addMonthsToDate directly and threw a bare parsing
error naming no cost, several frames below foldBalances. anchoredOccurrences
now checks its starting cursor's granularity before the walk begins and
throws a cost-named, actionable error instead. No new validation at either
write boundary — that stays out of scope here. stepFrom's docstring no longer
asserts a guarantee ("required to carry a day-granular startDate") that
nothing in the code enforces; it now says what actually protects it.

I1 (Important): the anchored walk's own termination throw had zero test
coverage — both surviving "termination" tests use the cost() fixture, whose
default anchoring is calendar, so they only ever exercised gridOccurrences.
Proven by mutation (replacing the anchored throw with break left every test
passing). Added a test that actually walks anchoredOccurrences, and corrected
the inline comment that falsely claimed coverage existed.

I2 (Important): confirmationsFor's throw is reachable from data the JSON
importer currently accepts (a calendar-cost purchase missing occurrenceDate),
not just a hypothetical programming error — its docstring said "programming
error upstream," which isn't true yet. Docstring corrected, and the throw is
now pinned by a test instead of only asserted in prose.

I3 (Important): nothing asserted that editing a charge's date backwards
re-anchors the series instead of throwing — exactly the behaviour deleting
requireConfirmationAdvances made structurally safe, and exactly the gap left
by deleting round-1's "a date more than one cycle before the slot it claims
is refused" test. Added the inverse: same fixture, but asserting the edit now
succeeds, the fold still folds cleanly, and the series re-anchors to the
edited date.

Minor: round-1 deleted ExpectedBand.test.ts's "stays suppressed once X3 no
longer applies" (X1-only), leaving its underlying scenario (day-granular
anchored cost, slot beyond the displayed month, X3 already satisfied)
completely untested even though still reachable. Added a test pinning what
expectedGroups actually does with it now that X1 is gone: the row is offered,
not suppressed — so a future reintroduction of suppression here is a visible,
deliberate change instead of a silent one.

Left untouched, as instructed: ExpectedBand.tsx's stale X1 docstring and the
Settings anchoring dropdown (both belong to later tasks).
2026-09-05 01:38:50 +02:00
Lucas Winther 3649a37cfa feat(domain): anchored costs have no slots
A lastCharge cost's next due date is one cycle after the latest actual charge.
So it can be charged any number of times per cycle, each charge restarts the
clock, and the reported bug is gone: two payments in September leave the next
due in October, not November.

calendar is untouched and keeps its grid. Paying September AND October's rent
really does mean nothing is due until November — the bug was only ever a bug
for the anchored case.

occurrencesOf's second parameter changes from a confirmations Map to the raw
purchase list: the anchored walk needs chargesFor's view (every Purchase.date
for the cost, latest wins), the grid walk needs confirmationsFor's view
(purchases keyed by the slot they settle), and only occurrencesOf itself knows
which to build. occurrencesByMonth now passes dataset.purchases straight
through instead of pre-building a map.

stepFrom's `anchoring === "lastCharge" && date !== monthOf(date)` guard (Task
2 scaffolding, for the old month-only-cursor walk) is deleted. The anchored
walk's cursor is always day-granular now (startDate or a real charge date), so
the condition was dead; if a month-only date ever reaches that branch,
addMonthsToDate throwing is correct, not a bug to paper over.

confirmationsFor's `if (!purchase.source.occurrenceDate) continue` scaffolding
guard (also Task 2) is now a thrown error instead: the function is calendar-
only, a lastCharge charge should never reach it, and continuing silently would
hide a programming error as a slot that merely looks unpaid.

DELETED: rebasedFrom, wouldAdvancePast, requireConfirmationAdvances, and the
tests that existed only to exercise them (6 in occurrences.test.ts describing
the old Map-keyed lastCharge slot-rebasing and its termination throw, 3 in
ExpectedBand.test.ts exercising the band's X1 "would this pairing advance the
series" suppression, 5 in actions.test.ts exercising
requireConfirmationAdvances's refusal and its pass-throughs — see each file's
CONTROLLER RULING comment for the line-by-line reasoning). All three existed
to stop a confirmation landing behind the slot it claims; with no slots on the
anchored path and no rebasing on the grid path, that situation cannot arise
any more. The anchored walk's own termination throw stays, as a defensive
assert against n < 1 — never softened into a break.

ExpectedBand.tsx and actions.ts also had their wouldAdvancePast-dependent code
removed (the X1 condition and requireConfirmationAdvances respectively) so
this task's suite would compile and run; Task 6/7 own reintroducing whatever
anchored-model-correct guard, if any, replaces them.

This removes more code than it adds.
2026-09-05 01:22:26 +02:00
Lucas Winther f8591fdc57 feat(types): occurrenceDate is optional, stepFrom takes anchoring
A lastCharge cost has no grid of slots, so a purchase against one settles
nothing — its own date is the whole story. occurrenceDate stays required for
calendar, where a slot is exactly what a payment settles.

stepFrom needs the anchoring because everyNMonths means two different things:
a month-granular grid under calendar, a day-precise clamped step under
lastCharge. The lastCharge branch only takes the day-precise path when its
input actually carries a day (date !== monthOf(date)) — a still-unconfirmed
or paid-on-schedule everyNMonths cost has no day to be precise about yet
(its startDate is documented as month-only), and without this guard the
existing walk crashes calling addMonthsToDate on a bare month. This also
means an off-schedule confirmation that rebases the series now correctly
stays day-precise afterward, which updates one pre-existing occurrencesOf
expectation (I3's "paying after the month" case) to match.

Three call sites read source.occurrenceDate as definite and are rewritten
properly in later tasks (3, 5, 6); each gets a narrow scaffolding guard
with a `// rewritten in Task N` comment rather than a cast or `!`.
2026-09-05 01:03:57 +02:00
Lucas WintherandClaude Opus 5 9035feed5f feat(domain): day-precise month stepping, clamped
A phone plan that refreshes on 20 September is next due 20 October, not
"October". The anchored path is day-granular, so month arithmetic finally
needs a clamp rule: 31 Jan + 1 month is 28 Feb.

Clamping measures from the ORIGINAL day rather than stepping month by month,
so 31 Jan + 2 months is 31 March and not 28 March. Date-free like the rest of
days.ts; daysInMonth already knew the leap rule.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-05 00:51:18 +02:00
Lucas Winther 8c3dbafc86 docs(plans): task-by-task plan for the anchored model
Nine tasks. One through three are the domain and run in strict order: the
clamped month step, the type and signature changes, then the split walk.
Four (migration), five (validation) and six (the band) each depend on three.
Seven (the split editor) is independent. Eight (the purchase dialog) needs
seven and five. Nine is docs and demo data.

Task three carries two deletions that belong tidily to tasks five and six.
They are done early because deleting wouldAdvancePast breaks compilation in
both files, and a task that cannot run its own suite cannot be reviewed.

Every expected value was checked by hand rather than derived from the
implementation: the 30-day step from 2026-09-12 lands on 2026-10-12 then
2026-11-11; 31 Aug + 6 months clamps to 28 Feb 2027; and 31 Jan + 2 months is
31 March, which only holds if the clamp measures from the original day instead
of stepping month by month. That last one is the trap in Task 1 and it has its
own test.

Flagged as the known risk: making SplitEditor generic touches the purchase
dialog, which is unrelated to recurring costs. The claim that PurchaseDraft
satisfies SplitTarget structurally is checkable by tsc alone and should be
verified before the rest of that task is written.
2026-09-05 00:45:38 +02:00
Lucas Winther c2b3d00381 docs(specs): the anchored model for recurring costs
The first version modelled the phone bill as "confirm a projected slot early",
and that was wrong at the root. A charge is not a slot claimed ahead of
schedule — it is an event that can happen at any moment, any number of times
per cycle, each one restarting the clock. There is no grid to claim; there is a
moving anchor.

Confirming twice in one September pushed the next due date to November, because
each confirmation consumed a future slot instead of resetting the anchor.
Reproduced under BOTH anchorings, which is what proves the defect is in the
slot model rather than in the rebase rule.

So the two anchorings get two projections, and the asymmetry is the design.
Paying September AND October's rent really does mean nothing is due until
November — for a calendar bill, consuming two slots is correct. Only the
anchored case is wrong, so only the anchored case changes.

The anchored walk cannot fail to advance: it steps forward from a real date
with n >= 1. That makes the entire class of "confirmation dated behind its
slot" defects unreachable — the class that produced three Criticals in round 1
— so wouldAdvancePast, the band's offerability guard and PATH A's
updatePurchase guard are all deleted. This change removes more code than it
adds.

everyNMonths becomes day-granular under lastCharge, since a plan refreshing on
20 September is next due 20 October, not "October". That finally forces a
month-clamp rule (31 Jan + 1 month is 28 Feb), confined to the anchored path.

Also closes the two additive requests: a cost's splits become editable through
the same SplitEditor purchases use, and the purchase dialog can tag a purchase
as a charge for a cost or create one from the purchase being typed. Tagging is
the refresh mechanism, so the data cap stops needing a flow of its own.

Self-review caught the band's Confirm being removed for anchored costs, which
would have made paying a bill on time slower to serve the exceptional case.
2026-09-05 00:38:10 +02:00
Lucas WintherandClaude Opus 5 f20a220dae Merge: recurring costs — bills that repeat, expected against actual
Rent every month, a subscription every 28 days, and a phone bill every 30 days
whose clock RESTARTS when its data cap is hit early. A recurring cost projects
EXPECTED charges forward; confirming one writes a real Purchase. The fold now
carries two balances per post per month: `remaining`, untouched by this feature,
and `projected`, which subtracts commitments that have not been paid yet.

The data-cap behaviour is not a feature of its own. Under `lastCharge`
anchoring the next occurrence is measured from the confirming purchase's OWN
date, so confirming a slot early rebases the series — and two-stage
confirmation already records that date. The domain never learns what a data cap
is. That collapse is most of why this design is small: three recurrence kinds
and one anchoring field, not a list of special cases.

Two consequences worth not re-litigating. `everyNMonths` carries no day,
because rent occurs IN September and IsoDate already permits "YYYY-MM" — which
keeps day arithmetic out of the commonest case. And `amount` is a single
mutable Money with no dated series, safe ONLY because confirmed purchases hold
what was actually paid; under projection-only it would destroy history.

Occurrences are computed, never stored. `upToMonth` comes from the caller, so
src/domain/ stays clock-free.

Schema 7. 651 tests, tsc clean, build green.

Four defects were found by review rather than by the suite, each reachable and
each invisible to a passing test:

- Creating a cost then switching its unit stored a month-only startDate on a
  day-granular kind, which threw out of the fold and took every money route
  down. Two clicks. The validators checked date SHAPE where the UI checked
  VALIDITY, so a calendar-impossible "2026-09-31" survived a second round.
- Confirming a future occurrence recorded today against a slot more than one
  cycle out, which walks the series backwards. Same crash, introduced by the
  change that closed a spec gap.
- Editing a confirmed purchase's date in the ordinary purchase dialog — an act
  with nothing to do with recurring costs — could do the same thing, and the
  dialog swallowed the refusal so a save appeared to silently do nothing.
- Import accepted a splitMode the fold would silently read as "fixed", turning
  intended percentages into absolute amounts.

AGENTS.md §3 held throughout: negatives, percentages past 100 and non-summing
splits are all still accepted, deliberately.

Design: docs/specs/2026-09-03-recurring-costs-design.md
Plan:   docs/plans/2026-09-03-recurring-costs.md

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-04 23:57:30 +02:00
Lucas WintherandClaude Opus 5 0c9df88a45 fix(recurring): refuse a confirmation date that stops the series
A confirmation is an ordinary Purchase, and the ordinary purchase dialog can
edit its date. Under lastCharge anchoring that date is what the walk measures
the next occurrence from, so moving it back more than a cycle made the walk
step backwards and throw out of foldBalances -- every month route, the year
view and the summary down at once, from an edit that never mentioned recurring
costs.

updatePurchase is the write that can invalidate an already-valid confirmation,
so it now asks wouldAdvancePast -- the same helper ExpectedBand asks before
offering a row -- and refuses the edit instead. The walk's throw is unchanged:
a step that lands backwards really is bad data, and this stops it being
created. A purchase with no source is not checked at all, and a source naming
a cost that no longer exists falls through.

PurchaseDialog was closing on save regardless of the outcome, with its
useMutate error rendered nowhere, so a refusal looked like a save that did
nothing. mutate now reports whether the write landed, and the dialog stays
open and shows the reason when it did not.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-04 23:55:38 +02:00
Lucas Winther 92e6b763f2 docs(recurring): record the coherence rule, and catch up §10-11 to what shipped
- AGENTS.md §1: reworded the "no horizon" invariant now that a parameter
  literally named horizonMonths exists. Nothing is stored — ExpectedBand's
  horizon only widens how far one read-only walk looks before the result is
  thrown away — but the old wording read as a contradiction to the next
  reader. The no-materialisation rule itself is unchanged.
- DECISIONS.md: added the "archived mirrors endedFrom" entry that was
  missing, including the previously-undocumented corollary that a
  future-dated cancellation dims the row immediately even though the bill
  keeps projecting until endedFrom.
- docs/specs/2026-09-03-recurring-costs-design.md §10-11: updated to
  describe the shipped UI — the "Coming up" group, the Ends column, the two
  coherence checks that gate a coming-up row (X1, X3), and the tests that
  now cover all of it.
2026-09-04 09:15:28 +02:00
Lucas Winther e3a1fb366b fix(recurring): updateRecurringCost can't touch archived/endedFrom (X4)
setRecurringCostEndedFrom is the only place that keeps archived and
endedFrom paired, and endRecurringCost/restoreRecurringCost/the Ends column
already delegate to it — but updateRecurringCost still typed its `changes`
as Partial<Omit<RecurringCost, "id">> and Object.assign'd it, so a caller
passing either field alone would desynchronise the pair. No current call
site does this, so it was latent; excluding both keys from the accepted
type makes it impossible rather than merely unexercised.
2026-09-04 09:15:20 +02:00
Lucas Winther 1281362388 fix(recurring): never offer a coming-up row that can't be confirmed coherently
Critical (X1): confirming a "Coming up" row records TODAY against a future
slot, and occurrencesOf throws — permanently, since the purchase persists —
if that pairing can't advance the series. Reproduced with the demo's own Car
insurance (everyNDays 30, lastCharge, from 2026-08-14): viewing 2026-09 and
confirming its coming-up row (slot 2026-10-13, recorded 2026-09-04) bricks
every month route, yearView, summaryView and PostYearRoute. `expectedGroups`
now omits a coming-up row outright (never disabled-with-an-explanation)
unless `wouldAdvancePast` says recording it today would leave the walk able
to advance past that slot.

Important (X3): a cost with an outstanding occurrence in the displayed month
or any earlier foldable month never gets a coming-up row either — that was
the double-counting path into X1 (Car insurance listed under both "Expected"
2026-09-13 and "Coming up" 2026-10-13; confirming the later while the earlier
stayed pending would double-count the bill). Settle the earlier bill first.

Important (X2): extracted the this-month-vs-coming-up recorded-date decision
into `recordedDateFor(group, today)`, previously inlined as `row(occurrence,
null)` vs `row(occurrence, today)` with nothing testing that the two aren't
swapped. Tested directly.

Minor: the "Coming up" label is now an `<h3>` (matching MonthRoute's own
group-heading convention) instead of a `<p>`, so assistive tech gets the
section divider.

New tests in ExpectedBand.test.ts cover all of the above, including a
regression built from the exact reported shape: confirming everything
expectedGroups offers as "coming up" and then folding must never throw.
Pre-fix, that reproduction throws:

  Recurring cost "Car insurance" did not advance past 2026-10-13
  (produced 2026-10-04). Purchase <id> claims that slot but is dated
  2026-09-04, which does not move the series past 2026-10-13 — correct
  its date so the series advances.
2026-09-04 09:15:11 +02:00
Lucas Winther 17f9a44b65 fix(domain): export wouldAdvancePast so a caller can check before confirming
occurrencesOf's "did not advance" throw is correct and permanent once a bad
purchase is written, so a caller planning to record a confirmation on a date
other than its slot needs to ask, up front, whether that pairing survives the
walk. Extract the rebase-from-a-confirmation rule (previously inlined once)
into `rebasedFrom`, reuse it from the walk itself, and build `wouldAdvancePast`
on top of it and `stepFrom` rather than re-deriving the rule a third time.

No behavioural change to occurrencesOf itself.
2026-09-04 09:14:59 +02:00
Lucas Winther b276573ffe feat(recurring): datable cancellation via an "Ends" column
Gap 2 (spec §10): endedFrom is typed as any IsoDate and the spec calls it
"cancellation", but Settings only ever wrote currentMonth — backdating or
forward-dating a cancellation was import-only.

Add an "Ends" column beside "Starts" in the Recurring costs table, edited and
validated the same way (isValidEndedFrom — no granularity rule, per §10, but
still refuses a calendar-impossible date like "2026-02-30"). Empty means "not
ended" and clears the cancellation.

Coherence rule: `archived` mirrors "endedFrom is set". setRecurringCostEndedFrom
is the one place that now enforces it — endRecurringCost and
restoreRecurringCost (the one-click row buttons) delegate to it, so the column
and the buttons can never leave a bill dimmed-but-live or ended-but-undimmed.

Also closes the same calendar-impossible gap in the JSON importer, which
checked endedFrom's shape but not its calendar validity, unlike startDate.
2026-09-04 08:46:57 +02:00
Lucas Winther 9fe950bc7d feat(recurring): offer confirmation on next occurrence beyond the month
Gap 1 (spec §5): ExpectedBand only listed this month's occurrences, so a
lastCharge bill whose pending slot had rolled into next month (the phone-bill
flagship case) was not confirmable without navigating there first.

Extend the walk 12 months past the displayed month (occurrencesByMonth's own
upToMonth bound; foldBalances is untouched) and surface a "Coming up" group:
at most one row per recurring cost, its next unconfirmed occurrence. That row
confirms EARLY, so it records today rather than the future slot date — which
is exactly what rebases a lastCharge series — and shows both dates so the
owner can see what will be written. `today` is exported from src/store/index.ts
next to `currentMonth`, keeping the clock out of src/domain/.

expectedGroups() is the pure, exported core (pending/overdue/comingUp);
ExpectedBand renders it and still returns null when all three are empty.
2026-09-04 08:46:45 +02:00
Lucas WintherandClaude Opus 5 042bc8597d fix(recurring): reject calendar-impossible startDate, not just wrongly-shaped (C1 residual)
requireStartDateGranularity (store/actions.ts) and its import-path twin
(export/json.ts) checked date SHAPE with a regex, not date VALIDITY, so
"2026-09-31" (September has 30 days) passed both and threw later out of
foldBalances instead of at the write boundary. isValidStartDate
(RecurringSection.tsx) had the same hole for a day-shaped value under
everyNMonths. Separately, monthOf's regex (domain/months.ts) was
unanchored at the end, so "2026-091" was silently read as September 2026.

Route day-shaped values through toDayOrdinal and month-shaped values
through monthOf (now anchored to accept only "YYYY-MM" or "YYYY-MM-DD" in
full, never a prefix) at all three write boundaries, converting whatever
they throw into each file's own error style.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-04 08:16:03 +02:00
Lucas Winther 405cf0bf2d docs+chore: everyNMonths is n-generic too; stop the demo double-charging Rent
PRODUCT.md described recurring costs as repeating "monthly, every N days,
or every N weeks", understating everyNMonths, which supports an interval
like the other two kinds.

demo-data.ts gave "Rent" both an ordinary monthly purchase and a
never-confirmed recurring cost from 2026-06 onward, so the demo's Rent
Projected read as double-charged for June through September. The ordinary
purchase now stops once the recurring cost exists.
2026-09-04 07:58:09 +02:00
Lucas Winther 08abe64b55 fix(ui): refuse to confirm an unparsed amount; stop counting pre-fold occurrences as overdue (I5, M9)
`parsed ?? undefined` conflated "field never touched" with "field edited
to text that doesn't parse" — both produced null, so typing "30 GBP" in a
DKK/USD dataset silently confirmed the purchase at the cost's own amount
instead, with no feedback. resolveExpectedAmount now distinguishes the
two: untouched still confirms at the cost's amount in one click (the fast
path is unchanged), but edited-and-unparseable disables Confirm and shows
an inline message.

Also excludes months before settings.foldStartMonth from the "earlier"
overdue count: foldBalances never folds those, so an occurrence out there
was never actually holding the projected balance down, and counting it
made the explanatory sentence below the count claim a gap that wasn't in
the number being shown.
2026-09-04 07:58:03 +02:00
Lucas Winther 093521b2c8 fix(views): keep a post visible when its only figure is an unconfirmed occurrence (I4)
hasActivity only checked carriedIn/allocation/charges, so an archived post
whose sole activity this month was an unconfirmed recurring occurrence
(zero remaining, nonzero expected/projected) was dropped from
monthView.rows — its Projected figure never reached the month view even
though ExpectedBand listed the same occurrence. remaining and projected
are both fully derived from five raw fields; the check now covers all
five.
2026-09-04 07:57:56 +02:00
Lucas Winther 5a047f53de fix(domain): a monthly lastCharge bill paid a few days early no longer throws (I3)
Under lastCharge anchoring, an everyNMonths cost stepped from the
confirmation's exact date even when that date fell earlier in the same
month as the slot it claims (e.g. rent for 2026-09 paid 2026-08-28). Since
stepFrom normalises through monthOf, that produced the same month as the
cursor, which the walk read as "did not advance" and threw — for a
completely ordinary early payment.

The month-granular step now runs from whichever is later, the cursor or
the confirmation's month, so a payment within the same month simply does
not rebase. A payment recorded in a LATER month still rebases the series
forward, symmetric with the day-granular cap-hit-early behaviour.

Also corrects the termination error's message, which claimed a purchase
was dated "more than one step earlier" regardless of how early it
actually was.
2026-09-04 07:57:51 +02:00
Lucas Winther eb13f98610 fix(ui): normalise startDate on a kind switch, validate the Starts field (C1 UI, C2)
The unit dropdown could rewrite a cost's recurrence kind to a day-granular
one without touching its month-only startDate, committing exactly the
pairing the store now refuses. It now expands startDate to the first of
the month in the SAME mutate call that changes the kind.

The Starts field itself was free text committed on every keystroke,
including an emptied field ("" reaches monthOf() and throws with the same
blast radius). It is now an uncontrolled input that only commits on blur,
and only when the text parses as an IsoDate of the granularity the cost's
recurrence kind requires; an invalid edit is discarded rather than stored.

isValidStartDate and normalizedStartDateFor are exported and covered
directly in RecurringSection.test.ts, since this is what stands between
this field and a repeat of C1/C2.
2026-09-04 07:57:37 +02:00
Lucas Winther 397bb245d8 fix(import): reject a startDate/kind granularity mismatch, and a
non-array recurring field (C1, M7)

parseDatasetJson now enforces the same rule as the store: everyNDays and
everyNWeeks require a day-granular startDate, since occurrencesOf hands it
straight to addDays. Without this, a hand-edited or exported-then-mutated
v7 file could import a cost that bricks every route touching the fold.
Updates the module docstring's claim that it "validates far enough that
the fold cannot throw" to actually cover this case.

Also switches the recurring field to requireArray, matching its four
sibling collections (posts/months/purchases/fxRates) — a non-array
`recurring` used to silently import as empty instead of being rejected.
2026-09-04 07:57:30 +02:00
Lucas Winther d1ccc65d72 fix(recurring): validate startDate granularity at both write paths (C1)
addRecurringCost and updateRecurringCost now reject a day-granular
recurrence (everyNDays/everyNWeeks) paired with a month-only startDate.
occurrencesOf hands startDate straight to addDays for those kinds, which
throws "Not a day-granular IsoDate" — reachable in two clicks by adding a
cost (always created as everyNMonths, startDate "YYYY-MM") and then
switching its unit in the dropdown without touching Starts.

updateRecurringCost checks the resolved pair (changes merged onto the
stored cost) on every call, not just one that touches recurrence or
startDate, since either field alone can break the pairing.

Also corrects endRecurringCost's doc comment: archiving does not hide a
recurring cost from the list, it dims it — the code already matches
PostsSection, only the comment was wrong (M6).
2026-09-04 07:57:23 +02:00
Lucas WintherandClaude Opus 5 8078057daa test(currency-digits): cover the recurring-cost expected/projected track
The zero-decimals guard drove every money path through rounding except
expectedForMonth and fold.ts's projected balance, added by the recurring
costs feature with the fixture's recurring array still empty. Adds an
unconfirmed, non-base-currency, fixed-split recurring cost so the FX
conversion and the split division both land on a non-whole amount at
zero digits, and extends the fold/month-view/two-decimals assertions to
cover it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-04 07:34:39 +02:00
Lucas Winther 0d2aa519b7 docs(recurring): record the invariants and what they cost
Two rules that only review catches, so they go in AGENTS.md §1: occurrences are
computed and never stored, and a confirmation's date and its slot are different
facts. Collapsing the second pair into one field would silently delete the
phone-bill behaviour, which is why it is written down rather than left to the
types.

§2 gets the termination guard. n >= 1 is rejected at both write boundaries
rather than clamped, and the walk throws rather than breaking, because a bill
silently dropped from a budget is worse than a visible error.

Demo data seeds a monthly rent and a 28-day subscription — the second is what
puts two occurrences in one month, which is the case worth looking at. It also
seeds a biweekly cost and a lastCharge-anchored one, so every recurrence kind
and both anchorings show up in Settings and the month view.

DECISIONS.md, PRODUCT.md, ARCHITECTURE.md and TODO.md are updated to match:
schema is at 7, the month view carries a second Projected balance beside
Remaining, and TODO.md records what a week of real use — not a test — will
have to answer.
2026-09-03 19:03:35 +02:00
Lucas WintherandClaude Opus 5 824e94a9bd feat(ui): surface the projected balance and fix interval clipping
PostTable gained a Projected column (both the sm: table and the
mobile block markup) so the fold's second balance — what is left
after unconfirmed recurring bills — is finally visible, muted so
Remaining stays the primary figure. The legend now explains it.

RecurringSection's interval input widened from w-14 to w-20 so
two-digit values like 28 and 30 no longer collide with the native
number spinner.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 18:52:25 +02:00
Lucas Winther b0324cb3e2 feat(month): confirm expected charges in one click
The repeated act, so it is a row and a button rather than a dialog — entry
speed is a product principle and a modal per bill would defeat it. The amount
is editable inline because paying a different figure is common; anything more
unusual is edited afterwards like any other purchase.

Confirmation is offered on a pending occurrence whose date has not arrived yet,
deliberately. A bill anchored to its last charge can charge EARLY, and
confirming the pending slot with the real date is how the series rebases.

The overdue count is not decoration. An occurrence nobody ever confirms holds
the projected balance down forever, which is the honest reading of an
unreconciled commitment but is invisible without it.

Returns null when nothing is expected, so a dataset with no recurring costs
sees no change to the month view at all.
2026-09-03 18:41:43 +02:00
Lucas Winther 72471914ba feat(settings): define recurring costs
Follows PostsSection: same table, same xs ghost row actions, same add row.
Definition is the rare act, so it lives here rather than in the month view.

Placed after Posts because a cost's splits reference posts, and before the
data section so the destructive import controls stay last.

'end' sets endedFrom to the current month and archives in one action, which is
what the owner means by cancelling a subscription. Past occurrences are
untouched, so no historical figure moves.
2026-09-03 18:30:22 +02:00
Lucas Winther 65543eb37b fix(import): close three validation gaps found in review round 1
I3: purchase.source: null (and a null entry in recurring[]) reached a bare
property access and threw a raw TypeError instead of ImportValidationError.
null is not undefined, so the existing `!== undefined` guard let it through;
source: null is a plausible hand-edit since the sibling field on the same
purchase literal is schedule: null. Both paths now check for null/non-object
explicitly and refuse with the file's own wording, matching the defensive
style already used two lines away (cost.amount?.currency,
schedule?.slices ?? []).

I1/I2: splitMode and splits[].value were unchecked on both purchases and
recurring costs. An unrecognised splitMode silently takes charges.ts's
"fixed" branch instead of "percent" (or vice versa), turning an intended
percentage into an absolute amount with no error. A non-finite split value
propagates as NaN through distributeByWeight/distributeByAmount, and
roundMoney(NaN, digits) returns NaN rather than throwing. Both are now
checked in one new requireSplits() helper shared by the purchases loop and
the recurring-costs loop, replacing each loop's separate copy of the
no-splits/one-absorber/unknown-post checks rather than adding a third copy.
Per AGENTS.md §3, requireSplits only rejects non-numbers: negative values,
values over 100, and splits that don't sum to the total stay deliberately
unchecked.
2026-09-03 18:24:25 +02:00
Lucas Winther 74ae4b21e7 feat(import): validate recurring costs at the boundary
The one check that is load-bearing rather than tidy: a recurrence interval
below 1, or a fractional one, is REFUSED rather than clamped. The projection
walk terminates only if every step strictly advances, and a clamped zero would
hand the owner a schedule their file does not describe — silently.

Purchase.source is checked too: the cost it names must exist, and the
occurrence date must parse. PURCHASE_DATE already accepts both YYYY-MM and
YYYY-MM-DD, which is exactly the range a slot date can take.
2026-09-03 18:14:21 +02:00
Lucas WintherandClaude Opus 5 bbe313b009 feat(store): recurring cost actions, and confirming an occurrence
confirmOccurrence writes an ordinary Purchase: the slot it claims goes in
source.occurrenceDate, the date the money moved goes on the purchase, and the
two differ whenever a bill is paid off schedule. Splits are copied rather than
shared, so editing the purchase cannot rewrite the cost.

endRecurringCost sets endedFrom AND archived, because they do different jobs —
one stops the projection, the other only hides the row. Archiving alone would
leave a cancelled bill projecting forever. Neither touches a past occurrence,
so no historical figure moves.

requireRecurrence rejects n below 1 and non-integers rather than clamping.
The walk terminates only if every step advances, and the importer is not the
only write path.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 18:08:32 +02:00
Lucas WintherandClaude Opus 5 3d3121aca2 feat(fold): a second balance for money committed but not moved
remaining is untouched: carriedIn + allocation - charges, exactly as before, so
no figure the owner has reconciled against their bank can move when they add a
recurring cost. projected runs beside it on its own carry, subtracting
unconfirmed occurrences as well.

Two invariants pin it, both tested. With no recurring costs the tracks are
identical in every month — that is the behaviour-preservation guard for every
dataset that exists today. Once a month's occurrences are all confirmed they
reconverge there, because a confirmation is an ordinary purchase counted in
charges and skipped by expected.

An unconfirmed occurrence in a PAST month therefore holds projected below
remaining forever. That is the honest reading — an unreconciled commitment —
and the month view surfaces the count so it is not silent.

Also required to typecheck once PostMonthFigures gains these fields:
- src/store/migrations.test.ts: delete the two @ts-expect-error directives
  that stood over the "NO figure changes" guard test since Task 2. That test
  is now green — the point of this task.
- src/ui/meterSegments.test.ts: the figures() test helper builds a complete
  PostMonthFigures literal; default expected to 0, projectedCarriedIn to
  carriedIn, and projected to remaining, matching its existing partial.X ??
  style.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 18:02:49 +02:00
Lucas Winther 324b6bd272 feat(domain): expected charges, sharing one split implementation
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.
2026-09-03 17:57:34 +02:00
Lucas WintherandClaude Opus 5 b41b219d94 feat(domain): project recurring occurrences, never store them
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]>
2026-09-03 03:47:21 +02:00
Lucas Winther a456aadf35 feat(types): recurring costs in the dataset, schema 7
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.
2026-09-03 03:41:48 +02:00
Lucas Winther de333685a2 feat(domain): day arithmetic without Date
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.
2026-09-03 03:34:32 +02:00
Lucas Winther 94e7a189b6 docs(plans): task-by-task plan for recurring costs
Ten tasks. One through five are the domain and run in strict order: day
arithmetic, types and the 6 -> 7 migration, the occurrence walk, expected
charges, then the fold's second track. Six and seven (actions, import
validation) need only the types. Eight and nine are the two UI surfaces. Ten is
demo data, documents and screenshots.

Task 2 deliberately leaves one test red. The behaviour-preservation guard —
every post in every month folds identically, projected equal to remaining —
is written where the risk is created and goes green in task 5, where the field
it asserts on exists.

Writing it out caught four things the spec could not:

- A fold assertion of -4000 where the accumulators give 4000. Left in, the
  implementer would have changed working code to match a wrong expectation.
- A second reachable cause for the walk's termination throw. Under lastCharge,
  a confirmation dated more than one step before the slot it claims moves the
  cursor backwards; a valid calendar series never can. The message now names
  both causes, because they need different fixes, and there is a test.
- parseMoneyInput takes (text, options, fallback). The options list is what
  makes "30$" resolve to USD rather than the fallback.
- demo-data.ts builds through actions.*, not literals, so the seeding task
  uses addRecurringCost with the ids already in scope.

Task 10 ends by stating what screenshots do NOT prove: how it looks and feels,
the overdue count against a real backlog, and whether two-stage confirmation
matches how the owner actually reconciles.
2026-09-03 03:29:39 +02:00
Lucas Winther 2096e11a85 docs(specs): recurring costs take 7, not 6
The decimals change landed first (8d2b2ca) and took schema 6, so this spec's
migration becomes 6 -> 7 and its undo path 7 -> 8. Both specs were drafted
claiming 6; only the number had to move, because the steps were always
independent — that one changed where digits live, this one adds an array.

Three places the same commit made stale rather than merely renumbered:

- The shared split helper is smaller than designed. chargesForPurchaseInMonth
  already takes digits: number rather than a currency table, so it needs no
  currency plumbing.
- Display is now a rounding boundary. The Expected band gets its formatters
  from useMoneyFormat(), not format.ts, and its amount field parses through
  parseMoneyInput as a text input.
- currencyDigits.test.ts no longer drives one zero-decimal CURRENCY; it drives
  the whole dataset at zero decimals. The expected track joins that sweep.
2026-09-03 03:29:26 +02:00
Lucas WintherandClaude Opus 5 8d2b2ca881 feat(money): one decimals setting for the dataset, not one per currency
`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]>
2026-09-03 03:12:14 +02:00
Lucas WintherandClaude Opus 5 ebef56eef4 fix(settings): widen the column, and end the currencies scrollbar
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]>
2026-09-03 02:52:26 +02:00
Lucas WintherandClaude Opus 5 596de45525 docs(specs): recurring costs, expected against actual
Bills that repeat: rent monthly, a subscription every 28 days, a phone bill
every 30 days whose clock restarts when the data cap is hit early.

A recurring cost projects expected charges forward; each occurrence is
confirmed into a real Purchase when the money moves. The fold gains a second
balance, so `remaining` never shifts and `projected` says what is left after
commitments. The two reconverge as a month settles.

The phone bill reads like a third recurrence kind and is not one. It charges
30 days after the PREVIOUS CHARGE, and the data cap only makes a charge happen
early — a date two-stage confirmation already records. So `anchoring` is one
field, `calendar` or `lastCharge`, and the domain never learns what a data cap
is. That collapse is most of why this design is small.

Two consequences worth not re-litigating later:

- `everyNMonths` carries no day. Rent occurs IN September, and `IsoDate`
  already permits a bare "YYYY-MM", so the commonest case needs no day
  arithmetic and no February clamping rule.
- `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.

Occurrences are computed, never stored — materialised snapshots were rejected
in the original design, and an unbounded recurrence would otherwise need a
horizon. `upToMonth` comes from the caller, so src/domain/ stays clock-free.

Takes schema 6. The global-decimals spec claimed 6 too and is reassigned to 7;
both specs now say so. The steps are independent and compose in either order.

Spec only. The two settings-layout fixes in the tree are unrelated and stay
unstaged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 02:50:05 +02:00
Lucas WintherandClaude Opus 5 d69e896c4c fix(theme): declare the live colour scheme, so Dark Reader stands down
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]>
2026-09-02 23:10:09 +02:00
Lucas WintherandClaude Opus 5 acb4d97870 fix(month): align purchase rows, and reach their actions on a phone
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]>
2026-09-02 22:59:27 +02:00
Lucas WintherandClaude Opus 5 cbab7358b0 fix(ui): cap the hint measure, and pad the last two bare controls
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]>
2026-09-02 22:59:14 +02:00
Lucas WintherandClaude Opus 5 7b7d36dc74 fix(views): segmented switches on the year and summary views
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]>
2026-09-02 22:59:04 +02:00
Lucas WintherandClaude Opus 5 25ad22a4e7 fix(settings): space the stacked bands, and give each row action a hit area
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]>
2026-09-02 22:58:55 +02:00
Lucas WintherandClaude Opus 5 f6be0c8439 fix(purchase): one control language in the purchase dialog
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 `&times;` 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]>
2026-09-02 22:58:44 +02:00
Lucas WintherandClaude Opus 5 85ba899059 fix(dialog): let the dialog's own padding decide its layout
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]>
2026-09-02 22:58:33 +02:00
Lucas WintherandClaude Opus 5 394c5f74df feat(ui): add a shared native select and a segmented control
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]>
2026-09-02 22:58:20 +02:00
Lucas WintherandClaude Opus 5 da8431a57f fix(ui): theme the radio tint and the text selection
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]>
2026-09-02 22:58:07 +02:00