docs: move the long-form docs into docs/, without the superpowers nesting
ARCHITECTURE, PRODUCT and TODO move out of the repository root, and
docs/superpowers/{specs,plans} flattens to docs/{specs,plans}:
"superpowers" names the tool that wrote those documents, not a kind of
document.
No content changes. This commit is the move, plus the roughly thirty
cross-references the move invalidated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
88bf001bff
commit
d83edccc79
21 files changed
+32
-32
No files matched your search
Executable
+181
@@ -0,0 +1,181 @@
|
||||
# Architecture
|
||||
|
||||
Orientation for anyone — human or agent — changing this codebase. It explains
|
||||
where things live and, more usefully, *why* several non-obvious choices are the
|
||||
way they are. Most of them were paid for with a bug.
|
||||
|
||||
## Shape
|
||||
|
||||
A personal budgeting PWA. No backend. IndexedDB in the owner's browser is the
|
||||
only copy of their data. One static artifact deploys to both GitHub Pages (under
|
||||
a subpath) and a self-hosted Docker image.
|
||||
|
||||
Bun is the runtime, package manager, bundler and test runner. React 19,
|
||||
Tailwind 4, shadcn/ui. No Vite, no npm, no jest.
|
||||
|
||||
## Layers
|
||||
|
||||
```
|
||||
src/domain/ pure. no React, no IndexedDB, no clock. testable with no DOM.
|
||||
src/store/ persistence, mutations, and the ONLY place a clock is read.
|
||||
src/export/ JSON and ODS generation. pure.
|
||||
src/ui/ React. reads through hooks, writes through store actions.
|
||||
build.ts static build: publicPath, hashed names, SW precache, 404.html.
|
||||
server.ts the Docker image's static file server.
|
||||
```
|
||||
|
||||
The dependency rule is one-directional: `ui -> store -> domain`. `domain` imports
|
||||
nothing from the others; `store` never imports from `ui`.
|
||||
|
||||
`src/domain/smoke.test.ts` asserts the domain is importable with no DOM. That is
|
||||
why happy-dom is registered **per test file** rather than as a global bunfig
|
||||
preload — a global DOM would make that assertion vacuously true and silently
|
||||
retire the check.
|
||||
|
||||
## The fold is the single source of every number
|
||||
|
||||
`src/domain/fold.ts` holds the one implementation of envelope rollover:
|
||||
|
||||
```
|
||||
remaining = carriedIn + allocation − charges (carried forward, monthly)
|
||||
```
|
||||
|
||||
Unspent carries; overspend carries as debt. **Nothing else may recompute this.**
|
||||
The month view, year matrix, summary, per-post views and the ODS export all
|
||||
aggregate over `foldBalances` / `figuresFor`. A closing balance is read from
|
||||
`byMonth[11].remaining`, never re-summed.
|
||||
|
||||
If you find yourself writing `carriedIn + allocation - charges` outside
|
||||
`fold.ts`, that is the invariant breaking. It matters because two screens
|
||||
silently disagreeing about someone's money is the worst failure this app has.
|
||||
|
||||
Editing any past month recomputes everything after it. That is correct: the fold
|
||||
is a left fold, and history is causal.
|
||||
|
||||
## Data flow for a single edit
|
||||
|
||||
```
|
||||
onChange
|
||||
└─ capture event.target.value into a const ← synchronously, see Gotchas
|
||||
└─ store.mutate(draft => action(draft))
|
||||
└─ serialization queue (one mutation at a time)
|
||||
└─ structuredClone(snapshot) → apply → await persistence.write()
|
||||
└─ snapshot = next; notify()
|
||||
└─ useSyncExternalStore re-renders
|
||||
```
|
||||
|
||||
Three deliberate properties in that chain:
|
||||
|
||||
- **Write before the snapshot moves.** If the write fails, the UI keeps showing
|
||||
the last *saved* state, and `useMutate` surfaces the rejection. The user is
|
||||
never shown data that was not persisted.
|
||||
- **Serialized.** Two edits inside one write window would otherwise both clone
|
||||
the same base and the later commit would silently drop the earlier one.
|
||||
- **Cloned.** `useSyncExternalStore` detects change by reference identity;
|
||||
mutating in place makes the UI silently stop re-rendering.
|
||||
|
||||
The two whole-dataset writes take the same queue and the same rules:
|
||||
`store.replace()` behind JSON import, and `store.reset()` behind Settings'
|
||||
"Reset everything". `reset()` calls the same `createSeedDataset` that `load()`
|
||||
uses on a first run, so "reset" and "a brand-new browser" cannot drift apart.
|
||||
Both are preceded in the UI by a JSON backup download that must succeed before
|
||||
the destructive write is attempted.
|
||||
|
||||
## Money
|
||||
|
||||
A float, deliberately, with two rules that make it safe:
|
||||
|
||||
1. **Round at every boundary** via `roundMoney` — after any division, after any
|
||||
FX conversion, and at the `src/store/actions.ts` mutation boundary. The UI
|
||||
adds no rounding of its own.
|
||||
2. **Never round the last distributed part independently.** Compute it as
|
||||
`total − sum(others)` so the parts sum exactly to the whole. `withRemainder`
|
||||
in `money.ts` is the only place that logic lives.
|
||||
|
||||
**`FxRate.baseUnitsPerOne` is never rounded.** Rates are stored at six decimals
|
||||
and are not money; quantizing one to cents is wrong in kind.
|
||||
|
||||
**Decimal places are data, not a constant.** `Dataset.currencies` records each
|
||||
currency's code, name, symbol and digits, because the owner can define their
|
||||
own. `roundMoney` therefore takes a digit count and every call site resolves it
|
||||
with `digitsFor` — required rather than defaulted, so a missed site is a
|
||||
compile error instead of something that works for every 2-decimal currency and
|
||||
breaks on the first zero-decimal one. `src/domain/currencyDigits.test.ts` drives
|
||||
a zero-decimal currency through the whole stack, which is the only test that can
|
||||
tell a real implementation from one that assumes 2.
|
||||
|
||||
## Splits and finance plans compose
|
||||
|
||||
A purchase can be split across posts *and* spread over months at once. The trick
|
||||
is in `charges.ts`: first compute how the **whole** purchase divides across posts
|
||||
(`splitPartsOfTotal`), then distribute each month's slice proportionally *to
|
||||
those parts*. Both split modes collapse to one code path per slice.
|
||||
|
||||
Applying a split's raw value per-slice looks simpler and passes every percentage
|
||||
test — and charges a fixed "2000 to Video Games" in full on every slice.
|
||||
|
||||
## Things the app deliberately does NOT validate
|
||||
|
||||
Adding a "missing" guard here is a regression, not a fix:
|
||||
|
||||
- going over budget, ever, anywhere;
|
||||
- allocation percentages summing past 100%;
|
||||
- splits that do not sum to the total (the remainder-absorbing split reconciles
|
||||
them by design);
|
||||
- negative amounts (a refund is a normal line).
|
||||
|
||||
## Gotchas paid for with real bugs
|
||||
|
||||
- **Never read `event.target.value` inside a `mutate()` callback.** `mutate`
|
||||
defers behind the queue and a write; React resets a controlled input's DOM
|
||||
value long before the callback runs, so you read the *old* value and commit
|
||||
it. This froze the income field at 0. `src/ui/eventCapture.test.ts` fails the
|
||||
build if it reappears.
|
||||
- **Read `process.env.BUN_PUBLIC_*` through a `try/catch` accessor.** Bun only
|
||||
inlines the literal when the variable is *set*; unset, the reference survives
|
||||
into the browser bundle and throws `ReferenceError: process is not defined`
|
||||
before the app boots. A `typeof process` guard does NOT work — after inlining
|
||||
it still runs in the browser and discards the inlined value, silently breaking
|
||||
subpath deploys. See `readBasePathEnv` in `src/ui/basePath.ts`.
|
||||
- **Do not define a bare `--accent` CSS variable.** shadcn owns it as a
|
||||
near-white hover *background* paired with near-black text; overriding it drops
|
||||
hover contrast below WCAG AA app-wide. Project tokens are namespaced
|
||||
`--budget-*`.
|
||||
- **`sw.js` must stay unhashed and be served `no-cache`.** A cached or hashed
|
||||
service worker can never be replaced, pinning users to an old build forever.
|
||||
- **A React error boundary does not catch throws from event handlers.** Domain
|
||||
functions that throw (`MissingRateError`, `remainderIndexOf`, `monthOf`,
|
||||
`equalSlices`) must be guarded at the call site when called from a handler.
|
||||
|
||||
## Testing, and its limits
|
||||
|
||||
Most logic is pure and tested directly with `bun test`. The domain carries the
|
||||
bulk of the coverage; the UI is thin by design.
|
||||
|
||||
**A browser IS available**, via `scripts/screenshot.ts` — headless Chrome
|
||||
driven over CDP, because Chrome's own `--screenshot` fires on the load event
|
||||
and captures an empty page before this app's async IndexedDB read resolves.
|
||||
That script is how the month view, the carry meter and the rule editors were
|
||||
first seen by anyone. It reports console errors too, which is the point: a page
|
||||
that renders blank because of a thrown error looks identical to an empty one.
|
||||
|
||||
Its limits are real. The container's fontconfig maps every generic family to a
|
||||
monospace face, so screenshots need a `FONTCONFIG_FILE` override to be
|
||||
representative; Chrome starts with an empty profile each run; and no screenshot
|
||||
can judge how a design feels. Real offline behaviour, the install and update
|
||||
prompts, and the `.ods` opening in a spreadsheet remain unverifiable here.
|
||||
Where a check could not be run, the honest substitute was used and recorded —
|
||||
the `.ods` is validated structurally by unzipping it and asserting `mimetype`
|
||||
is first and stored, every XML part parses, and numeric cells carry
|
||||
`office:value`.
|
||||
|
||||
`src/ui/eventCapture.test.ts` is a *source-level* guard rather than a
|
||||
behavioural one, because the bug it prevents depends on real browser render
|
||||
timing that happy-dom does not reproduce.
|
||||
|
||||
## Where the reasoning lives
|
||||
|
||||
- `docs/PRODUCT.md` — durable product truth: who it is for, what must never break.
|
||||
- `docs/TODO.md` — queued work, with the reasoning behind each decision already made.
|
||||
- `docs/specs/` — design decisions and the alternatives rejected.
|
||||
- `docs/plans/` — task-by-task implementation plans.
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
# Product
|
||||
|
||||
<!-- impeccable:product-schema 1 -->
|
||||
|
||||
## Platform
|
||||
|
||||
web
|
||||
|
||||
## Users
|
||||
|
||||
One person: the owner of the money, managing their own budget. They are leaving
|
||||
a spreadsheet, so they already know their own categories, already think in
|
||||
months, and value speed of entry over guidance. They tolerate rough edges in
|
||||
exchange for the tool doing exactly what they mean.
|
||||
|
||||
Confirmed permanently single-user. One person, one browser, one dataset. There
|
||||
are no accounts, no second role, and no shared or household use planned.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Envelope budgeting for a single person, replacing a spreadsheet.
|
||||
|
||||
Money is divided each month into named envelopes ("posts"). Unspent allocation
|
||||
carries into the next month; overspending carries forward as debt. The app
|
||||
answers two questions at a glance: how much is left in this envelope, and did I
|
||||
overspend.
|
||||
|
||||
Success is that the owner stops maintaining a spreadsheet, and that a month's
|
||||
purchases can be entered fast enough not to be a chore.
|
||||
|
||||
## Positioning
|
||||
|
||||
Two things a general budgeting app does not do:
|
||||
|
||||
- **Envelope rollover is the whole model, not a feature.** Every figure derives
|
||||
from one left-fold across months, so a balance is always the honest
|
||||
consequence of every prior month.
|
||||
- **The data never leaves the browser and never needs an account.** IndexedDB is
|
||||
the only store. That is a deliberate product position, not a missing backend.
|
||||
|
||||
## Operating Context
|
||||
|
||||
The owner records spending after the fact, often in batches — a grocery trip is
|
||||
twenty lines entered in one sitting, not twenty separate visits. Income arrives
|
||||
monthly and varies, so it is entered per month rather than assumed.
|
||||
|
||||
Purchases frequently belong to more than one envelope (food bought at a social
|
||||
event), and larger purchases are spread across several months deliberately
|
||||
rather than absorbed by one.
|
||||
|
||||
Money is handled in DKK, USD and EUR, with DKK as the base.
|
||||
|
||||
## Capabilities and Constraints
|
||||
|
||||
Confirmed and in place:
|
||||
|
||||
- Posts (envelopes) with monthly allocation, created and archived by the owner.
|
||||
Posts are archived, never deleted, because purchases reference them.
|
||||
- Allocation is either a fixed amount or a percentage of that month's income.
|
||||
- A purchase can be split across several posts, by percentage or by fixed
|
||||
amounts, and can also be spread across future months as a payment plan. The
|
||||
two compose.
|
||||
- Per-month income, entered directly, varying month to month.
|
||||
- Multi-currency (DKK/USD/EUR) against a user-maintained exchange-rate table,
|
||||
with an optional on-demand fetch that fails soft and is never required.
|
||||
- JSON export/import and ODS (spreadsheet) export, both generated in the
|
||||
browser.
|
||||
- Offline-capable installable PWA. Currently deploys as one static artifact to
|
||||
both GitHub Pages (under a subpath) and a self-hosted Docker image.
|
||||
|
||||
Terminology: an envelope is called a **post**. A **finance plan** is a purchase
|
||||
spread across months. An **override** is a one-month exception to a post's
|
||||
allocation.
|
||||
|
||||
Undecided: nothing outstanding at the product level.
|
||||
|
||||
## Brand Commitments
|
||||
|
||||
The product is called **Budget 2.0**. No logo, wordmark, or brand assets exist,
|
||||
and none have been commissioned.
|
||||
|
||||
## Evidence on Hand
|
||||
|
||||
None, and none may be invented. This is a personal tool: there are no
|
||||
customers, testimonials, case studies, usage statistics, press, pricing, or
|
||||
third-party integrations. Any future surface must not manufacture them.
|
||||
|
||||
The only real content is the owner's own budget data, which never leaves their
|
||||
browser and is therefore never available as example material. Illustrative data
|
||||
must be visibly synthetic.
|
||||
|
||||
## Product Principles
|
||||
|
||||
1. **Over-budget is always allowed.** The app reports the truth and never
|
||||
blocks, clamps, or hides overspending. A negative balance is a normal value.
|
||||
2. **Changing an allocation must never destroy history.** Adjusting what a post
|
||||
gets going forward leaves what it got before intact and still visible.
|
||||
3. **Splitting a purchase across posts must stay easy.** It is a routine act,
|
||||
not an advanced feature, and must not be buried behind extra steps.
|
||||
4. **The data stays portable and in the owner's hands.** Export is always
|
||||
available and always complete; nothing traps a budget inside the app.
|
||||
5. **Entry speed is a feature.** Recording a month of spending is the most
|
||||
repeated act in the product, and friction there is the reason spreadsheets
|
||||
get abandoned.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
No condition-specific requirement was established by the owner. The existing
|
||||
implementation holds itself to WCAG AA contrast, visible keyboard focus, and
|
||||
respecting reduced-motion preferences; keyboard-first entry is a stated goal of
|
||||
the fast-entry flow rather than only an accessibility accommodation.
|
||||
Executable
+215
@@ -0,0 +1,215 @@
|
||||
# TODO
|
||||
|
||||
Work queued for whoever picks this up next, human or agent. Ordered. Each item
|
||||
says enough to act on without re-deriving the reasoning.
|
||||
|
||||
**Current state:** merged to `main`. 443 tests passing, `bunx tsc --noEmit`
|
||||
clean, `bun run build` succeeds on both the live-fetch and offline paths.
|
||||
Sections 2, 4 and 7 are DONE. Section 3 (redesign) is 2 of 4 sub-projects in.
|
||||
Section 6 (currencies) is DONE. Every screen has now actually been looked at —
|
||||
see section 5.
|
||||
|
||||
**Schema is at version 3.** v1 -> v2 made allocation rules a dated series;
|
||||
v2 -> v3 moved currencies into the dataset. Both migrations are
|
||||
behaviour-preserving by construction and tested as such.
|
||||
|
||||
---
|
||||
|
||||
## 1. ~~Time-versioned allocation rules~~ — DONE, all six tasks
|
||||
|
||||
**Spec:** `docs/specs/2026-09-02-versioned-allocation-rules-design.md`
|
||||
**Plan:** `docs/plans/2026-09-02-versioned-allocation-rules.md`
|
||||
**Ledger + extracted briefs:** `.superpowers/sdd/2026-09-02-versioned-allocation-rules/`
|
||||
|
||||
| Task | What | Commit |
|
||||
|---|---|---|
|
||||
| 1 | `Post.rules: RuleVersion[]` replaces `standingRule` | `68712e7` |
|
||||
| 2 | v1 -> v2 migration | `15dcf93`, `1214d2b` |
|
||||
| 3 | `setRuleFrom` / `removeRuleFrom` actions | `de128e3` |
|
||||
| 4 | JSON import validation for the rule series | `1cb91d3` |
|
||||
| 5 | Rule-history editor in Settings | `e3023d0` |
|
||||
| 6 | "Change from here" control in the month view | `f94e458` |
|
||||
|
||||
Tasks 3-6 were executed by hand rather than dispatched. Every deviation from
|
||||
the briefs is recorded in the ledger's task log with its reasoning; four of
|
||||
them fixed real defects in the brief code (JSX that would not compile, a
|
||||
component that pre-filled the previous post's rule, a number field that could
|
||||
not be cleared, and missing validation that let a NaN allocation into the
|
||||
fold). Worth a read before reviewing the diff.
|
||||
|
||||
Not yet done for this feature: nobody has independently reviewed tasks 3-6, and
|
||||
no browser has seen the two new editing surfaces.
|
||||
|
||||
## 2. ~~Baked FX rates~~ — DONE (`b4e1933`)
|
||||
|
||||
So a fresh install can convert a EUR purchase on day one instead of erroring.
|
||||
Built as designed, all three build paths verified (live fetch, unreachable
|
||||
endpoint, `SKIP_FX_FETCH=1`), and confirmed in the real UI: Settings shows
|
||||
USD 6.449532 / EUR 7.474959 as `manual · 2026-09-01` on a fresh dataset.
|
||||
The reasoning below is kept because it is the reasoning, not a plan.
|
||||
|
||||
- `build.ts` fetches DKK/USD/EUR at build time and embeds them via `define`;
|
||||
on any network failure it falls back to **committed constants** rather than
|
||||
failing the build. Fetched rates get `source: "api"` with the build date;
|
||||
the fallback constants get `source: "manual"`, so Settings never claims a
|
||||
hardcoded number came from an API.
|
||||
- They **seed a new dataset only**. They must NOT act as a fallback whenever a
|
||||
rate is missing — that would undo a deliberate decision: clearing a rate
|
||||
removes the row so `MissingRateError` surfaces, instead of silently
|
||||
converting money at a number the user never chose.
|
||||
- Domain purity forces the shape: `createSeedDataset` takes the rates as an
|
||||
**argument**; the env read lives in a new `src/store/bakedRates.ts`, in the
|
||||
store layer, using the `try/catch` accessor pattern (see CLAUDE.md gotchas).
|
||||
|
||||
## 3. Visual redesign — sub-projects 1 and 2 of 4 DONE
|
||||
|
||||
The owner's words: *"right now it looks a bit boring, simple and ugly. We want
|
||||
sleek, modern, and nice to use."* Then, 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."*
|
||||
|
||||
**Spec:** `docs/specs/2026-09-02-design-foundation-design.md`
|
||||
|
||||
**The diagnosis, which should shape the rest:** 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.
|
||||
|
||||
| # | Sub-project | State |
|
||||
|---|---|---|
|
||||
| 1 | Design foundation — two-theme tokens, theme toggle, self-hosted Inter + JetBrains Mono, one Section/Stat card, table furniture | DONE (`bb3c661`) |
|
||||
| 2 | Month view | DONE. Date-grouped purchases (`a159189`), then the header, the post rows and the carry meter |
|
||||
| 3 | Year, Summary, Post detail | on the new foundation but not rethought. The 12-month matrix only fits by scrolling — decide whether compact number formatting replaces that |
|
||||
| 4 | Dialogs and fast entry | untouched. Native `<select>` elements are still browser-default, and `up`/`down`/`archive` are bare text buttons |
|
||||
|
||||
**Sub-project 2, for whoever does 3 and 4.** Spec:
|
||||
`docs/specs/2026-09-02-month-view-design.md`. Three things it
|
||||
settled that the reporting screens should follow rather than re-decide:
|
||||
|
||||
- **Meter geometry is `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 and has 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.
|
||||
|
||||
Also worth knowing: `src/ui/cssTokens.test.ts` matches source *text*, so it
|
||||
flags a token named in a code comment as readily as one in a `style` — which is
|
||||
exactly what it did to the comment explaining it.
|
||||
|
||||
**Watch for:** the brand teal is `--primary`, never `--accent` — shadcn's
|
||||
`--accent` is a hover *background* whose paired foreground must move with it.
|
||||
`src/cssPairs.test.ts` enforces the pairing, and `src/ui/cssTokens.test.ts`
|
||||
fails if a component names a token `index.css` does not define. That second
|
||||
guard exists because renaming `--rule` silently made the carry meter paint
|
||||
nothing: an undefined custom property inside `linear-gradient()` invalidates
|
||||
the whole declaration without warning.
|
||||
|
||||
## 4. ~~Smaller items~~ — ALL DONE
|
||||
|
||||
All six, `b9be882` / `71e8fc9` / `f50c65b`. Three were worse than recorded:
|
||||
|
||||
- `confirmImport` now guards its write, and a source-level check covers the
|
||||
whole UI (`b9be882`).
|
||||
- The PNG icons **were** generatable here — Chrome is on PATH and rasterizes
|
||||
the SVG exactly. `scripts/make-icons.ts` produces them; they are committed,
|
||||
precached, and pixel-verified against the source (`f50c65b`).
|
||||
- Split values now round by what they MEAN — money in "fixed" mode, a
|
||||
percentage in "percent" mode. Narrower than the note claimed: `round2` was
|
||||
always right for `Rule.percent`, which is not money (`b9be882`).
|
||||
- `EMPTY_FIGURES` is frozen (`b9be882`).
|
||||
- The ODS export was **not** "fine at personal-budget scale": 30 posts over 10
|
||||
years took 7983 ms synchronously on the main thread. Now 135 ms, with
|
||||
byte-identical output (`71e8fc9`).
|
||||
- The dev manifest did not merely hardcode `scope: "/"` — it was never served
|
||||
at all; the catch-all answered with the HTML shell. One definition now feeds
|
||||
the build and the dev server (`b9be882`).
|
||||
|
||||
## 6. ~~User-defined currencies, and amounts that carry their currency~~ — DONE
|
||||
|
||||
Both `cdece63`. `Currency` is an open string code and `Dataset.currencies` is
|
||||
the authority on validity, symbols and decimal places; the purchase total
|
||||
field reads "30$" as USD 30.
|
||||
|
||||
**The part worth knowing before touching money again:** `roundMoney(amount,
|
||||
digits)` takes a digit count, and its ~29 call sites each resolve digits from
|
||||
the dataset with `digitsFor`. The argument is required deliberately — every
|
||||
currency the app shipped with uses two decimals, so a default of 2 would leave
|
||||
a missed call site passing every test and silently wrong for the first
|
||||
zero-decimal currency added. `src/domain/currencyDigits.test.ts` is the only
|
||||
test that can tell a real implementation from one that assumes 2: it drives a
|
||||
JPY-style currency through rounding, both FX directions, both split modes,
|
||||
plan division, the fold and the month view.
|
||||
|
||||
Left undone, deliberately:
|
||||
|
||||
- **The income field and the split editor's fixed-amount fields do not parse a
|
||||
currency yet** — 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 one 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.
|
||||
|
||||
## 7. ~~Reset to a fresh install~~ — DONE
|
||||
|
||||
Settings > Your data > "Reset everything…". The owner asked for a reset that
|
||||
includes the currencies and the seed categories, and chose fresh-install state
|
||||
over an empty dataset: the three starter posts, the DKK/USD/EUR table, the
|
||||
baked rates, this month at zero income, no purchases.
|
||||
|
||||
- `store.reset()` calls the same `createSeedDataset(currentMonth, seedRates)`
|
||||
that `load()` uses on a first run, through the same write queue. Seeding
|
||||
lives in the store, not the UI, so "reset" and "a brand-new browser" cannot
|
||||
drift apart.
|
||||
- The confirm panel reuses the import flow's shape and its **order of
|
||||
operations**: the JSON backup downloads BEFORE the destructive write, and a
|
||||
backup that fails aborts the reset. This is the user's only copy.
|
||||
- Verified in Chrome by clicking through and reading IndexedDB back: 3 seed
|
||||
posts, DKK/USD/EUR, both baked manual rates, 0 purchases, one month at zero
|
||||
income.
|
||||
|
||||
## 5. Still needs a human
|
||||
|
||||
`scripts/screenshot.ts` drives headless Chrome, so the list here is much
|
||||
shorter than it was. **Seen and confirmed working:** the carry meter (it
|
||||
renders, and shows red where a post is overspent), the rule-history editor in
|
||||
Settings, the "change from here" control, the baked FX rates arriving in a
|
||||
fresh dataset, and every route at desktop and mobile widths.
|
||||
|
||||
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`) —
|
||||
before that fix it had never installed once, in any environment, because
|
||||
`cache.addAll` rejects on a duplicate request. What is still unverified is
|
||||
a real offline load with the network actually cut, and Chrome's install
|
||||
prompt appearing.
|
||||
- the generated **`.ods` opening in a real spreadsheet** (validated
|
||||
structurally: mimetype first and stored, well-formed XML, numeric cells).
|
||||
- **the pre-destructive backup actually landing as a file.** Both the import
|
||||
and the reset flow download a JSON backup before replacing the dataset, and
|
||||
the code path demonstrably runs (the reset proceeds only when it returns
|
||||
success). Whether headless Chrome wrote the file is not observable here, so
|
||||
nobody has yet confirmed the backup is openable — only that it was issued.
|
||||
- **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.
|
||||
Regular → Executable
+4
-4
@@ -15,7 +15,7 @@
|
||||
|
||||
**Tech Stack:** Bun 1.3+ (runtime, package manager, bundler, test runner), React 19, Tailwind 4, shadcn/ui, `fflate` for ODS zip generation, hand-written service worker. No Vite. No Electron.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-01-budget-app-design.md`
|
||||
**Spec:** `docs/specs/2026-09-01-budget-app-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
@@ -177,7 +177,7 @@ The scaffolded `CLAUDE.md` documents Bun idioms. Append the rules specific to th
|
||||
reimplement the math.
|
||||
- Over-budget is always allowed. Never add validation that blocks it.
|
||||
- Base path comes from `BUN_PUBLIC_BASE_PATH`; never hardcode a subpath.
|
||||
- Read the spec at `docs/superpowers/specs/2026-09-01-budget-app-design.md`.
|
||||
- Read the spec at `docs/specs/2026-09-01-budget-app-design.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Ignore the IDE directory**
|
||||
@@ -7357,8 +7357,8 @@ your current data first.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Design: `docs/superpowers/specs/2026-09-01-budget-app-design.md`
|
||||
- Implementation plan: `docs/superpowers/plans/2026-09-01-budget-app.md`
|
||||
- Design: `docs/specs/2026-09-01-budget-app-design.md`
|
||||
- Implementation plan: `docs/plans/2026-09-01-budget-app.md`
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Final verification**
|
||||
Regular → Executable
+1
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
**Tech Stack:** Bun (runtime, package manager, test runner), TypeScript strict, React 19, Tailwind 4, shadcn/ui. No new dependencies.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-02-versioned-allocation-rules-design.md`
|
||||
**Spec:** `docs/specs/2026-09-02-versioned-allocation-rules-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
Regular → Executable
+2
-2
@@ -315,5 +315,5 @@ migrations are mandatory rather than optional.
|
||||
invariants: the domain layer stays free of React and IndexedDB, money
|
||||
rounds at every boundary, the last distributed part is computed by
|
||||
subtraction, and the rollover fold has exactly one implementation.
|
||||
- `docs/superpowers/specs/` — this document
|
||||
- `docs/superpowers/plans/` — the implementation plan, written next
|
||||
- `docs/specs/` — this document
|
||||
- `docs/plans/` — the implementation plan, written next
|
||||
Regular → Executable
+1
-1
@@ -76,7 +76,7 @@ rather than on the arithmetic.
|
||||
|
||||
Inter for text, JetBrains Mono for figures, both variable, both self-hosted,
|
||||
latin + latin-ext only. SIL OFL 1.1; licences committed beside the files.
|
||||
Self-hosted rather than CDN-linked because PRODUCT.md's position is that the
|
||||
Self-hosted rather than CDN-linked because docs/PRODUCT.md's position is that the
|
||||
data never leaves the browser and the app needs no third party — a font request
|
||||
to another origin on every cold load quietly contradicts that.
|
||||
|
||||
Regular → Executable
File renamed without changes.
Regular → Executable
+2
-2
@@ -19,7 +19,7 @@ Three problems, found by looking at the rendered page in Chrome at 1280px and
|
||||
Income, Allocated, Spent and Unallocated sat in one row at one weight. The
|
||||
income *input* — the only editable thing on the screen — was in the same visual
|
||||
register as three numbers derived from it. And nothing at the month level
|
||||
answered the second of PRODUCT.md's two questions, *did I overspend*: with
|
||||
answered the second of docs/PRODUCT.md's two questions, *did I overspend*: with
|
||||
Transport at −3,200 DKK, you had to read down the Remaining column to find out.
|
||||
|
||||
**The carry meter belonged to the wrong row.** It is the design contract's
|
||||
@@ -41,7 +41,7 @@ Three zones in reading order, in the one `Section` card, on the paper ground
|
||||
below the unchanged month `<header>`.
|
||||
|
||||
1. **Income** — the field. Unchanged in behaviour: still `type="number"`, still
|
||||
base-currency only. Section 6 of TODO.md records the reasoning — a month has
|
||||
base-currency only. Section 6 of docs/TODO.md records the reasoning — a month has
|
||||
one income, so making the field parse a currency the way the purchase total
|
||||
does is arguably wrong rather than merely undone. It keeps field affordance
|
||||
so it stays visibly the one editable thing on the screen.
|
||||
Regular → Executable
File renamed without changes.
Reference in new issue
Block a user