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.
305 lines
17 KiB
Markdown
Executable File
305 lines
17 KiB
Markdown
Executable File
# TODO
|
||
|
||
Work still queued, for whoever picks it up next, human or agent. Ordered. Each
|
||
item says enough to act on without re-deriving the reasoning.
|
||
|
||
Finished work is not listed here. It is in the commit messages, and the
|
||
decisions behind it are in `docs/specs/` — one spec per sub-project,
|
||
each recording the alternatives that were rejected. Rules that outlived their
|
||
task moved to `AGENTS.md`.
|
||
|
||
**Current state:** the anchored model is merged with budget folders and
|
||
placement. The month pie showcase, browser-stored session backups, and
|
||
unassigned recurring costs are implemented. 881 tests pass across 64 files;
|
||
`bunx tsc --noEmit` is clean, and `SKIP_FX_FETCH=1 bun run build` succeeds
|
||
using the committed seed rates.
|
||
|
||
**Schema is at version 11.** v1 -> v2 made allocation rules a dated series;
|
||
v2 -> v3 moved currencies into the dataset; v3 -> v4 drops a stored rate-service
|
||
URL that can no longer work; v4 -> v5 adds sterling to an existing dataset;
|
||
v5 -> v6 collapses per-currency decimals into one dataset-wide setting; v6 -> v7
|
||
adds `Dataset.recurring`; v7 -> v8 adds budget folders and assigns existing
|
||
posts and recurring bills a deterministic mixed sibling order under Unfiled.
|
||
v8 -> v9 adds optional purchase and bill placement fields without moving old rows;
|
||
v9 -> v10 nests existing unfiled single-post bills under their post.
|
||
v10 -> v11 moves a `lastCharge` cost off slots — it strips `occurrenceDate`
|
||
from every anchored charge, expands a month-only `startDate` on an anchored
|
||
`everyNMonths` cost to a day-granular one, and expands a month-only
|
||
`Purchase.date` on every anchored charge (the day comes from the
|
||
`occurrenceDate` being stripped when that names one in the same month,
|
||
otherwise the 1st). The first nine preserve financial behaviour — not one
|
||
changes a stored figure. The tenth changes no stored figure either, but it is
|
||
NOT behaviour-preserving by construction: an anchored cost's projected due
|
||
dates genuinely change shape, because they no longer come from a grid at all.
|
||
All ten are tested.
|
||
|
||
A migration applies **on load, in memory**; it is written back on the next
|
||
mutation, not by `load()` itself. So a freshly migrated dataset shows the new
|
||
shape immediately while IndexedDB still holds the old version for a while. That
|
||
is not a bug, but it will mislead you if you verify a migration by reading
|
||
IndexedDB instead of the app.
|
||
|
||
---
|
||
|
||
## Optional monthly income forecast
|
||
|
||
Add a user-controlled toggle that predicts a month's income from earlier months.
|
||
For each earlier month, divide its recorded income by that month's number of
|
||
workdays; average those per-workday figures, then multiply by the target month's
|
||
workdays. Let the owner adjust the prediction when income changes, for example
|
||
after a raise, without rewriting recorded income or earlier months. Show
|
||
predicted and entered income distinctly, and keep budgeting based on entered
|
||
income unless the owner explicitly chooses otherwise. Before implementation,
|
||
settle whether workdays mean Monday–Friday only or exclude local holidays, and
|
||
whether an adjustment applies to one month or all future predictions.
|
||
|
||
## 1. Folder rename blur during an in-flight save
|
||
|
||
A folder name draft can remain unsaved if the owner changes it while a rename
|
||
save is in flight and then blurs the field before that save finishes. Preserve
|
||
the newer draft and ensure the blur requests a subsequent save after the pending
|
||
write completes. Cover the delayed-write sequence with a UI interaction test,
|
||
including the final persisted name and any surfaced write error.
|
||
|
||
## 2. Recurring split editor interaction coverage
|
||
|
||
`RecurringSplitEditor` has coverage for destination selection and dedicated post
|
||
creation, but its rendered split mode and value controls lack a focused
|
||
interaction test. Exercise the real controls to switch percentage/fixed mode
|
||
and edit a split value, then assert the resulting splits and remainder behavior.
|
||
Keep this scoped to the editor's user interaction; domain split calculations
|
||
already have their own tests.
|
||
|
||
## 3. Two things the recurring-cost model gets wrong, both known
|
||
|
||
Neither is a bug introduced by the anchored model. Both are recorded here
|
||
because they were found while reviewing it, and both are the kind of thing a
|
||
future reader would otherwise rediscover the hard way.
|
||
|
||
### Editing a live calendar cost re-grids it and orphans its charges
|
||
|
||
`updateRecurringCost` refuses an `anchoring` change once a cost has charges
|
||
(AGENTS.md §2 says why), but it ACCEPTS `recurrence` and `startDate` changes on
|
||
the same charged cost. For a `calendar` cost those two fields ARE the grid, so
|
||
changing either moves every slot — and every existing charge still names, in
|
||
`source.occurrenceDate`, a slot that no longer exists. The confirmation stops
|
||
matching anything, the slot it settled reappears as unpaid, and the bill is
|
||
counted twice: once as the purchase that was really paid, once as an
|
||
expectation that never was.
|
||
|
||
Reproduction, run and confirmed:
|
||
|
||
1. A `calendar` cost, `everyNDays` with `n: 7`, 1000 kr, starting 2026-09-01,
|
||
allocated 5000 kr in a 2026-09 with 20000 income.
|
||
2. Confirm the SECOND slot — `confirmOccurrence(cost, "2026-09-08")`, one real
|
||
purchase carrying `occurrenceDate: "2026-09-08"`.
|
||
3. September reads `expected 4000`, `projected 0`, `remaining 4000`.
|
||
4. Change `n` to 5. Nothing else.
|
||
5. September reads `expected 6000`, `projected -2000`, `remaining 4000`.
|
||
|
||
The paid bill now counts as both a charge and an expectation. Nothing warns.
|
||
|
||
The second slot, not the first, on purpose: the new grid (01, 06, 11, …) still
|
||
contains 2026-09-01, so a confirmation of the FIRST slot survives the re-grid
|
||
by luck and the damage is smaller (`expected 5000`, `projected -1000` — still
|
||
wrong, because the grid gained a slot). Any confirmed slot the new grid does
|
||
not happen to contain is orphaned outright.
|
||
|
||
Do NOT patch this by silently rewriting the stored `occurrenceDate`s — there
|
||
is no honest mapping from one grid to another, which is exactly the reasoning
|
||
that made an anchoring change refusable. The real question is a product one:
|
||
what should editing a live bill MEAN? Plausible answers are refusing the edit
|
||
the way an anchoring change is refused, ending the cost and starting a new one
|
||
(what the owner is told to do today for anchoring), or giving `RecurringCost`
|
||
a dated series the way `Post.rules` already has, so a rent increase or a
|
||
schedule change takes effect from a month rather than retroactively. That is a
|
||
design decision with a spec behind it, not a patch.
|
||
|
||
### An anchored cost has no HISTORY of expectations
|
||
|
||
This follows from the spec and is not a defect, but it sits in tension with
|
||
AGENTS.md §1's "archiving must not retroactively remove past expected charges",
|
||
so it deserves saying out loud.
|
||
|
||
The anchored walk emits occurrences from ONE anchor: the latest charge.
|
||
Recording a charge therefore erases every earlier month's expectation for that
|
||
cost — there is nothing left in the dataset that says a charge was once
|
||
expected in July. Consequences, all by design:
|
||
|
||
- `overdue` in `ExpectedBand` is 0 for any anchored cost that has ever been
|
||
charged — which is every real one, after its first charge. It counts
|
||
unconfirmed occurrences in earlier months, and an anchored cost projects
|
||
none behind its anchor. (An anchored cost with NO charges yet does project
|
||
into the past from its `startDate`, and those do count, so the figure is not
|
||
structurally zero; it collapses to zero the moment a charge is recorded.)
|
||
- A PAST month's `projected` figure moves when a charge is recorded today,
|
||
because the expectation that month used to carry stops being projected.
|
||
`remaining` — the real, spent-money figure — is untouched, so the historical
|
||
record of what happened is intact; it is the projection that is not
|
||
historical.
|
||
|
||
Measured on a monthly anchored cost of 300 kr starting 2026-06-01, in a
|
||
dataset folding from 2026-06 with the current month at 2026-09. Before any
|
||
charge: July reads `expected 300`, `projected 9400`, `remaining 10000`, and
|
||
`overdue` is 3. After one charge dated 2026-09-04: July reads `expected 0`,
|
||
`projected 10000`, `remaining 10000`, and `overdue` is 0.
|
||
|
||
A `calendar` cost behaves the way §1 describes: its grid is fixed, so past
|
||
slots stay put whether they were ever settled or not. The two anchorings differ
|
||
here on purpose (AGENTS.md §1's schedule invariant), and unifying them would
|
||
reintroduce the bug the anchored model exists to fix.
|
||
|
||
## 4. Anchored-model follow-ups left after the merge with main
|
||
|
||
The anchored model was merged on 2026-09-30, after main had gained budget
|
||
folders, placement and nested bills. Everything below is known and unfinished.
|
||
|
||
### A bill created from the purchase dialog has no folder choice
|
||
|
||
Settings' new-bill form picks a folder; the dialog's "+ New recurring cost…"
|
||
does not. The new bill nests under its post when it has exactly one split
|
||
(the same default Settings applies), otherwise it lands in Unfiled and has to
|
||
be moved from Budget folders. Decide whether the dialog needs the picker or
|
||
whether moving it afterwards is fine for an occasional action.
|
||
|
||
### "Unlink from bill" was replaced by the Recurring cost select
|
||
|
||
Main had an "Unlink from bill" checkbox on an edited purchase; the merge
|
||
dropped it because choosing "None" in the anchored model's "Recurring cost"
|
||
select does the same thing (and `updatePurchase` now honours an explicit
|
||
`source: undefined`). Its copy said "the occurrence will show as expected
|
||
again", which is true for a `calendar` bill and meaningless for a
|
||
`lastCharge` one, whose next due date simply re-derives from the remaining
|
||
charges. Confirm the owner does not miss the checkbox; nothing tests its
|
||
absence.
|
||
|
||
### Not yet driven in a browser
|
||
|
||
The merge was checked by the full suite, `tsc`, a build, and screenshots of
|
||
the month page and Settings with demo data (both render, no console errors,
|
||
Car insurance shows "Next 2026-10-19" after charges on 3 and 19 September).
|
||
Not yet clicked through by anyone:
|
||
|
||
- tagging a new purchase to an existing anchored bill and seeing its next due
|
||
date move in the Budget tree and in Expected;
|
||
- "+ New recurring cost…" end to end, including where the new bill appears in
|
||
Budget folders;
|
||
- untagging an edited purchase via "None";
|
||
- the Settings anchoring control switching a never-charged monthly bill to
|
||
"the last charge" (its month-only start should expand to the 1st and the
|
||
Month/Date precision control should disappear), and being locked once the
|
||
bill has a charge.
|
||
|
||
## 5. Visual redesign, sub-project 3 — Year, Summary, Post detail
|
||
|
||
The owner's brief for the whole redesign: *"right now it looks a bit boring,
|
||
simple and ugly. We want sleek, modern, and nice to use."* On scoping: both
|
||
themes with a toggle, a full rethink of every screen, purchases compact and
|
||
grouped by date, ship a webfont — and *"override everything you want for this
|
||
step."*
|
||
|
||
Sub-projects 1 (design foundation, `bb3c661`) and 2 (month view, `7b71811`) are
|
||
done, each with a spec in `docs/specs/`. These three screens are on
|
||
the new foundation but have not been rethought. The open question is the
|
||
12-month matrix: it only fits by scrolling, and compact number formatting may be
|
||
what replaces that rather than a scroll container.
|
||
|
||
**The diagnosis that shaped the decomposition, which still applies:** a design
|
||
system already existed and was never wired up. `src/index.css` defined the
|
||
paper/ink/teal palette with a contract document behind it, while the app painted
|
||
shadcn's default white and near-black; the tokens reached the screen in two
|
||
places only. Doing the foundation last is how that happened. Do not repeat it —
|
||
extend the foundation rather than styling a screen locally.
|
||
|
||
**Three things sub-project 2 settled that these screens should inherit rather
|
||
than re-decide** (spec: `2026-09-02-month-view-design.md`):
|
||
|
||
- **Meter geometry lives in `src/ui/meterSegments.ts`**, pure and tested, with
|
||
`<Meter>` as the only thing that paints it. A third meter belongs there too,
|
||
not inline in a component. Negatives clamp rather than invert, because a
|
||
segment of negative width puts the gradient's stops out of order and paints
|
||
something arbitrary instead of failing.
|
||
- **Two markups, not one reflowing grid.** The post table is a real `<table>`
|
||
from `sm:` up and a list of two-line blocks below it. At 390px the layout
|
||
genuinely stops being a table, and `role="table"` on it would describe rows
|
||
and columns that no longer exist on screen. The year matrix faces the same
|
||
question with more columns to lose.
|
||
- **State the currency once per table, not per cell.** Every figure in the post
|
||
table is base currency, so `formatSignedAmount` prints bare numbers and the
|
||
legend names DKK once. This is what stopped "+4,219.61 DKK" wrapping on a
|
||
phone. `formatMoney` is still right wherever an amount can be in some other
|
||
currency — every purchase row, for one.
|
||
|
||
## 6. Visual redesign, sub-project 4 — dialogs and fast entry
|
||
|
||
Untouched. Native `<select>` elements are still browser-default, and
|
||
`up` / `down` / `archive` in Settings are bare text buttons. Covers the purchase
|
||
dialog, the split editor, the plan editor and bulk entry.
|
||
|
||
There is already a spec for fast-entry *behaviour* —
|
||
`2026-09-02-fast-entry-refinements-design.md`. Read it before restyling, so the
|
||
visual work does not fight it.
|
||
|
||
## 7. Currency work deliberately left undone
|
||
|
||
All three were decided against at the time, not forgotten. Reopen only with a
|
||
reason.
|
||
|
||
- **The income field and the split editor's fixed-amount fields do not parse a
|
||
currency** — only the purchase total does. Same helper (`parseMoneyInput`),
|
||
same three-line pattern. The income field is arguably wrong to make
|
||
multi-currency at all, since a month has one income.
|
||
- **No currency picker convenience.** Adding a currency means typing the code,
|
||
name, symbol and decimals by hand. A bundled ISO 4217 list would be nicer, and
|
||
is pure data.
|
||
- **Symbol collisions resolve to the first currency defined with that symbol.**
|
||
Fine for one "$" currency; if the owner adds two, typing the code
|
||
disambiguates and nothing warns them. Tested, documented, not solved.
|
||
|
||
## 8. Still needs a human
|
||
|
||
`scripts/screenshot.ts` drives headless Chrome, so this list is much shorter
|
||
than it was. Already seen and confirmed working: the carry meter (including red
|
||
where a post is overspent), the rule-history editor, "change from here", the
|
||
baked FX rates arriving in a fresh dataset, the reset writing a fresh dataset to
|
||
IndexedDB, and every route at desktop and mobile widths in both themes.
|
||
|
||
Genuinely outstanding:
|
||
|
||
- **How it looks and feels.** A screenshot proves a layout is not broken. It
|
||
cannot tell you the design is good, and the container's fonts are not the
|
||
owner's fonts.
|
||
- **Offline behaviour** and the **install prompt**. The service worker itself is
|
||
no longer unverified: it demonstrably installs, activates, claims the page,
|
||
precaches all 11 assets, and applies an update cleanly (`e6da85e`). What is
|
||
still unverified is a real offline load with the network actually cut, and
|
||
Chrome's install prompt appearing.
|
||
- **The pre-destructive backup actually landing as a file.** Import and reset
|
||
both download a JSON backup before replacing the dataset, and only proceed
|
||
when that reports success — but headless Chrome's download is not observable
|
||
here, so the backup is *issued*, never confirmed openable.
|
||
- **The generated `.ods` opening in a real spreadsheet.** Validated
|
||
structurally: mimetype first and stored, well-formed XML, numeric cells.
|
||
- **Keyboard flow in fast entry** — Tab traversal, Backspace-to-remove, and
|
||
focus placement after a row auto-appends. Drivable over CDP in principle; not
|
||
yet done.
|
||
- **GitHub Actions** has never been executed.
|
||
- **Whether two-stage confirmation matches how the owner actually reconciles a
|
||
bank statement.** Recurring costs (`docs/specs/2026-09-03-recurring-costs-design.md`)
|
||
project expected occurrences and require a click to turn one into a real
|
||
purchase, rather than charging automatically. That is a product bet a week of
|
||
real use answers and no test can.
|
||
- **Whether the anchored (`lastCharge`) model actually matches how the
|
||
owner's phone plan bills.** The design
|
||
(`docs/specs/2026-09-05-recurring-costs-anchored-model.md`) rebases the
|
||
series on whatever was last charged, restarting the clock every time — a
|
||
reasonable model of "refreshes when the data runs out", built from the
|
||
owner's own description rather than from watching a real bill arrive. Only
|
||
a cycle or two of real use confirms the shape is right.
|
||
- **The overdue count against a large, real backlog.** `ExpectedBand` counts
|
||
unconfirmed occurrences from earlier months, and demo data does now
|
||
accumulate a modest one (a few months of an unconfirmed monthly bill plus a
|
||
biweekly one, ~14 occurrences by September) that renders without incident.
|
||
Nobody has seen the count or the layout against years of neglect or dozens of
|
||
distinct bills.
|