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:
Lucas WintherandClaude Opus 5 committed 2026-09-02 21:56:59 +02:00
1 parent 88bf001bff
commit d83edccc79
21 files changed
+32 -32

No files matched your search

+319
View File
@@ -0,0 +1,319 @@
# Budget 2.0 — Design
**Date:** 2026-09-01
**Status:** Approved for planning
## Purpose
A personal budgeting app for a single user migrating off a spreadsheet. It
tracks variable monthly income, divides it into named envelopes ("posts"),
records purchases against them, and reports by month, by year, and in
aggregate. It must run offline, be self-hostable, and export to formats the
user can open in a spreadsheet.
## Non-goals
These are deliberately excluded. Adding any of them is a new design cycle.
- Multi-user accounts, authentication, or sharing
- Server-side storage or cross-device sync
- Merging two divergent datasets on import
- Bank/CSV transaction import
- Interest, fees, or APR on finance plans
- Live FX rates as a required dependency
## Constraints
| Constraint | Consequence |
|---|---|
| Deployable to GitHub Pages | No backend in the data path. The app is a static bundle. |
| Self-hostable via Docker | The same static bundle, served by a minimal Bun handler. |
| Must work on a subpath | Base path is configurable at build time, not hardcoded. |
| Offline-capable PWA | Service worker precaches the shell; all data is local. |
| Bun toolchain, no Vite | `Bun.build` provides `publicPath` and hashed `naming`. |
| No Electron | Installable PWA instead. |
## Architecture
**Pure derivation core over a fact store.**
IndexedDB stores only raw facts. A dependency-free TypeScript domain package
computes every derived figure — monthly views, running post balances, yearly
rollups, the summary — as pure functions with no React, no IndexedDB, and no
ambient clock access.
Rationale: envelope rollover makes every month depend on the entire chain
before it, so any materialised cache is invalidated by nearly every edit.
The dataset is a few thousand rows over a decade, making recomputation
effectively free. The pure core is also testable without a DOM, which is what
keeps the hard logic verifiable.
Rejected alternatives:
- **Materialised month snapshots.** Under rollover semantics every month is
downstream of every edit, so invalidation degenerates to full
recomputation while adding stale-cache failure modes.
- **SQLite-wasm in the browser.** Ships a multi-megabyte wasm blob into an
offline-first PWA, brings OPFS persistence quirks, and still needs
hand-written mapping for JSON and ODS export.
### Stack
- Scaffolded with `bun init --react=shadcn` (React 19, Tailwind 4, shadcn/ui)
- Bun as runtime, package manager, bundler, and test runner
- shadcn/ui components are vendored into the repo and owned by this project
- `fflate` for client-side zip generation (ODS export)
### Layers
```
src/domain/ pure functions + types. No React, no IndexedDB, no Date.now().
src/store/ IndexedDB persistence, migrations, in-memory snapshot.
src/ui/ React views and components.
build.ts static build: publicPath, hashed names, SW manifest, 404.html.
```
The domain layer must remain importable by a test file with no browser
environment. That is an architectural invariant, not a style preference.
## Build and deploy
- **Dev:** `bun --hot src/index.ts` — the scaffolded `Bun.serve` with HMR and
browser-console echo. Its `/api/*` routes and `APITester.tsx` are removed;
there is no backend, so the dev server only serves the HTML entrypoint.
- **Build:** `bun run build.ts` emits a fully static `dist/`, with
`publicPath` set from the base path and `naming: '[name]-[hash].[ext]'`
for cache-busting. Minification in production builds only.
- **Deploy:** one artifact, two hosts. GitHub Actions publishes `dist/` to
Pages. The Dockerfile copies the same `dist/` into an `oven/bun` image
behind a minimal `Bun.serve` static handler.
- **Base path:** a single `BUN_PUBLIC_BASE_PATH` env var (the scaffolded
`bunfig.toml` already exposes `BUN_PUBLIC_*` to client code) drives
`publicPath`, the router basename, the manifest link, and the
service-worker scope. Defaults to `/`; Pages builds use `/budget2.0/`.
- **GitHub Pages deep links:** Pages has no rewrite rules, so `build.ts`
also writes `dist/404.html` as a copy of `index.html`. The service
worker's navigation fallback covers this once installed.
## Data model
### Money
Money is a **float** paired with a currency code. Because binary floats
cannot represent decimal fractions exactly, two rules are mandatory
everywhere money is computed:
1. **Round at every boundary** — after each division, after each FX
conversion, and before persisting. Rounding is to the currency's minor
unit (2 decimal places for DKK, USD, EUR).
2. **Compute the last part as `total - sum(others)`** rather than rounding it
independently, so distributed parts always sum exactly to the whole.
Tests compare rounded values, never raw floats.
### Entities
```ts
type Currency = "DKK" | "USD" | "EUR"
type Money = { amount: number; currency: Currency } // amount is a float
type MonthId = string // "2026-09"
Settings { baseCurrency: Currency
foldStartMonth: MonthId
schemaVersion: number }
FxRate { currency: Currency
baseUnitsPerOne: number // how many base units 1 of `currency` buys
updatedAt: string
source: "manual" | "api" }
// one global table, not per-month
Post { id, name, order: number, archived: boolean
currency: Currency // display currency for this post's own views
standingRule: Rule }
Rule = { kind: "fixed", amount: Money } // any currency; converted to base
| { kind: "percentOfIncome", percent: number }
Month { id: MonthId
income: Money // always in Settings.baseCurrency
ruleOverrides: Record<PostId, Rule> }
Purchase { id, date: string, description: string
total: Money // the purchase's own currency
splitMode: "percent" | "fixed" // applies to all splits
splits: Split[]
schedule: Schedule | null }
Split { postId
value: number // a percentage, or an amount in the
// purchase's currency, per splitMode
absorbsRemainder: boolean }
Schedule { slices: { month: MonthId, amount: Money }[]
cancelledFromMonth?: MonthId }
```
**Explicit rules, so none of the above can be read two ways:**
- `FxRate.baseUnitsPerOne` is *base units per one unit of the named currency*.
With base DKK, `{ currency: "EUR", baseUnitsPerOne: 7.46 }` means 1 EUR is
7.46 DKK. The base currency itself has no row.
- **`splitMode` is per purchase, not per split.** All splits on one purchase
are either percentages or fixed amounts. Mixing the two within a purchase
("100 kr to Food, then split the rest") is not supported.
- Fixed split values are denominated in the **purchase's** currency, never the
post's and never the base.
- Percentages in `splits` should total 100, and fixed values should total
`Purchase.total`, but the remainder-absorbing split reconciles any shortfall
or excess so the parts always sum exactly to the total. Exactly one split
must have `absorbsRemainder: true`.
- `Month.income` is always in `Settings.baseCurrency`. Posts have no
independent income.
- A `Rule` of kind `fixed` may name any currency; it is converted to base
when the fold runs.
- `Schedule.cancelledFromMonth` means slices in that month **and every month
after it** are ignored by the fold. Earlier slices remain as history.
- `Settings.foldStartMonth` is set once, on first run, to the month of the
user's earliest data, and can be changed in settings. Months before it
contribute nothing and every post's carried-in balance there is zero.
- `Post.currency` affects only how that post's own monthly and yearly views
render figures. All cross-post arithmetic and every total happen in the base
currency.
**One `Purchase` entity covers all three cases.** A simple expense has one
split and no schedule. A split purchase has several splits. A financed
purchase has a schedule. Splits and schedules therefore compose with no
special-casing: a financed, split purchase distributes each slice across its
splits by ratio.
### Derived values
- `allocation(post, month)` — `month.ruleOverrides[post.id] ?? post.standingRule`,
resolved against that month's income. Percentages across posts **may sum
past 100%**; the app does not prevent this.
- `charges(month)` — every purchase whose schedule (or, absent a schedule,
whose `date`) touches that month; the slice amount is distributed across
splits by ratio, converted to base currency via the global FX table, with
the remainder assigned to the split flagged `absorbsRemainder`.
- `balance(post, month)` — `balance(post, previousMonth) + allocation − charges`,
folded forward from `Settings.foldStartMonth`.
- Year view and Summary are aggregations over that same fold. The rollover
math has exactly one implementation.
### Semantics
- **Rollover is envelope-style.** Unspent allocation carries into the next
month; overspend carries forward as debt (a negative balance). Editing a
past purchase recomputes every subsequent month.
- **Over-budget is always permitted.** Negative balances receive a warning
treatment in the UI but never block saving.
- **Allocation rules are standing.** Each post carries a default rule that
applies to every month automatically. Any single month may override it, and
overrides are visibly marked in the UI.
- **Posts are user-managed**, seeded with *Video Games*, *Food*, and *Events
and Social*. They can be created, renamed, reordered, and archived.
Archiving removes a post from new-entry pickers while its history continues
to fold. There is no hard delete, because purchases reference posts.
- **Finance plans are first-class.** A plan defaults to an equal split across
N months, and each slice amount may be edited individually. A plan can be
edited or cancelled as a unit; cancelling drops future slices and preserves
past ones as history.
- **Income is per-month** and entered directly, because it varies. It is a
single household figure; posts have allocations, not independent incomes.
- **FX is a single global table**, user-editable, with an optional on-demand
"fetch rates" action from the client. The fetch is never required: it fails
soft and retains the cached rates.
## UI
All routes sit under the configurable basename.
| Route | Purpose |
|---|---|
| `/month/:yyyy-mm` | Income input; table of posts with carried-in balance, allocation, spent, remaining. Unallocated income shown as its own figure. |
| `/year/:yyyy` | Posts × 12 months matrix, toggleable between *spend* and *closing balance*, with an income row and yearly totals. |
| `/post/:id/month/:yyyy-mm` | One envelope in detail: its purchases that month, plus incoming plan slices flagged as already-committed. |
| `/post/:id/year/:yyyy` | That post's 12-month trend and closing balance. |
| `/summary` | All expenditure across any range, grouped by post or month, filterable. |
| `/settings` | Base currency, FX table with fetch button, post management, export/import. |
**Purchase dialog** — a single form for all three shapes: date, description,
total plus currency, then two optional sections. The **split editor** offers a
percent/fixed toggle, a live remainder readout, and nominates one post to
absorb rounding. The **finance plan** toggle takes a month count to generate
equal slices, each individually editable against a running "allocated vs
total" indicator. Both sections, either, or neither.
**State** — the whole dataset fits in memory: one in-memory snapshot as the
single source of truth, write-through to IndexedDB on every mutation, and
memoized selectors over the pure core. There is no cache to invalidate.
## Export and import
**JSON** — the full dataset with its `schemaVersion`: settings, FX table,
posts, months, purchases. Import is **replace-all**. It first exports a
backup of current state, then shows what is about to be replaced (counts of
posts, months, purchases) and requires confirmation. Merge is out of scope:
reconciling two divergent histories has no correct answer without a sync
model.
**ODS** — generated entirely in-browser with `fflate`, so it works offline.
An ODS file is a zip of XML: `mimetype` first and uncompressed, then
`META-INF/manifest.xml`, `content.xml`, `styles.xml`. Sheets:
- **Summary** — posts × months, with totals
- **Months** — income, per-post allocation and spend
- **Purchases** — the raw ledger, one row per split-slice, so it pivots cleanly
- **One sheet per post**
Amounts are written as real numeric cells (`office:value`), not strings, so
the output can be pivoted and summed in a spreadsheet.
## Offline and updates
The app is fully functional offline by construction, since data never leaves
the browser. No PWA plugin exists for Bun, so the service worker and manifest
are hand-written and owned by this project; `build.ts` emits the precache
list from `Bun.build`'s `result.outputs`, keeping hashed filenames in sync
automatically.
- Cache-first for hashed assets; navigation fallback to `index.html`
- A new build prompts "update available" and calls `skipWaiting` on user
action. Without this, a stale service worker can pin the user to an old
version indefinitely.
- Manifest declares `display: standalone` and a `scope` matching the base path
**Schema migrations** — `schemaVersion` plus ordered migration functions
applied on load. IndexedDB holds the user's only copy of their data, so
migrations are mandatory rather than optional.
## Testing
`bun test`, weighted almost entirely toward the pure domain core.
- **Rollover fold** — unspent carries forward; overspend carries as debt;
editing an old month recomputes every later month
- **Split reconciliation** — distributed parts sum exactly to the total across
many randomised split configurations
- **Plan slicing** — equal default, custom per-slice amounts, cancellation
preserving past slices and dropping future ones
- **Allocation** — percent-of-income across varying incomes, override
precedence over standing rules, sums past 100% permitted
- **Archived posts** — excluded from pickers, still included in the fold
- **FX conversion** — rounding at boundaries, missing rate handled explicitly
- **Round-trip** — JSON export, then import, yields identical derived output
- **ODS** — the generated file is unzipped in-test and numeric cells asserted
- **Component tests** — limited to the purchase dialog's validation, under
happy-dom
- **CI** — additionally builds the Docker image and curls the served page
## Agent support files
- `CLAUDE.md` — the `bun init` scaffold provides Bun idioms ("use `bun test`",
"don't use vite", HTML imports). This project extends it with its own
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/specs/` — this document
- `docs/plans/` — the implementation plan, written next
+152
View File
@@ -0,0 +1,152 @@
# Design foundation
Sub-project 1 of the visual redesign. Owner's brief: *"right now it looks a bit
boring, simple and ugly. We want sleek, modern, and nice to use."* Owner's
answers to the four scoping questions: both themes with a toggle, a full
rethink of every screen, purchases compact and grouped by date, and a
self-hosted webfont. Owner then delegated the specifics: *"Override everything
you want for this step. This needs to look good."*
## The diagnosis that shaped everything else
A design system already existed and was never wired up. `src/index.css`
defined `--paper` (annotated "cool statement-paper ground, NOT cream"),
`--ink`, a muted teal, and surplus/overspend colours, with a design-contract
document behind them. The app painted itself with shadcn's defaults —
`bg-background text-foreground`, i.e. white and near-black. Those tokens
reached the screen in exactly two places: the carry meter, and the sign
colours on figures.
So "boring, simple and ugly" was not a missing design. It was a design that was
specified and then bypassed. That is why the foundation comes first in the
decomposition, and why it re-themes shadcn rather than styling screens: doing
it screen by screen is how the bypass happened.
## Decomposition
The redesign is four sub-projects, each its own spec → plan → implementation:
1. **Design foundation** (this document) — tokens, themes, type, shared furniture.
2. **Month view** — income and totals, post rows and carry meter, purchases grouped by date.
3. **Year, Summary, Post detail** — the reporting screens, including whether the 12-month matrix stays a scrolling table.
4. **Dialogs and fast entry** — purchase dialog, split editor, plan editor, bulk entry.
A single spec covering all four would have meant dozens of layout decisions
made on the owner's behalf under one approval.
## Token architecture
Two layers, deliberately separate.
**Layer 1, `--budget-*`**: the app's own vocabulary — paper, raised, ink,
ink-muted, rule, rule-strong, accent, accent-ink, tint — plus `--surplus` and
`--overspend`, which shadcn has no concept of. Defined once per theme. The only
colours app components may name.
**Layer 2, shadcn**: its entire variable set points at layer 1. Every primitive
(dialog, select, badge, button) inherits both themes for free.
### The brand teal is `--primary`, never `--accent`
CLAUDE.md's hardest-won rule is "never define a bare `--accent`", paid for with
an app-wide contrast bug. The mechanism matters: shadcn uses `--accent` as a
hover **background** with `--accent-foreground` as the text on it. Setting one
without the other repaints the background and leaves the text behind.
Two conclusions follow, and both are enforced rather than remembered:
- Every shadcn pair is redefined **together**, in both themes.
`src/cssPairs.test.ts` fails the build if either half of a pair appears
without the other, in any block.
- The saturated teal belongs on `--primary`. Putting a brand colour on
`--accent` — which is what "override `--accent`" invites — turns every hover
into a coloured slab with dark text on it. `--accent` stays a tint of the
surface.
### Contrast was computed, not chosen
Weakest text pair: 5.50:1 (muted on paper, light) against a 4.5:1 requirement.
Field borders were iterated until they cleared the 3:1 non-text requirement on
**both** backgrounds they appear against — the first two candidates measured
2.17 and 2.55 and were rejected. The final palette was then read back out of
the live browser in both themes, so the check is on what actually rendered
rather than on the arithmetic.
## Typography
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 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.
`.font-money` carries tabular figures and a slashed zero, so columns align on
the decimal and `0` cannot be misread as `O`.
### Rejected: @font-face in the bundled stylesheet
The obvious home. Bun's CSS bundler resolves every `url()` from disk and inlines
woff2 as base64 — **relative or absolute**, and `loader: { ".woff2": "file" }`
does not change it. Measured cost: `index.css` went from 70 KB to 303 KB of
render-blocking bytes, and the `unicode-range` subsetting became pointless,
since a subset embedded in the stylesheet downloads whether or not one
character needs it.
Also rejected: a `<link rel="stylesheet" href="/fonts.css">`. It works in a
production build (`external: ["*fonts.css"]`), but the dev server's HTML
bundler resolves every `<link href>` from disk and has no `external` option, so
dev would serve a placeholder and screenshots would lie about typography.
Making the manifest and icon hrefs root-absolute for the same reason 500'd the
dev server outright.
**Chosen**: `src/fontCss.ts` is the one definition, injected as a `<style>` at
boot by `frontend.tsx`. Identical in dev and in a subpath build, no placeholder
file, no href rewriting. The cost is that faces are requested after the JS
parses rather than from the HTML; `font-display: swap` keeps text readable
throughout, and the service worker precaches all four files.
## Theme switching
Cycles system → light → dark. Three states because "follow the system" is a
real answer and, once you have chosen otherwise, there must be a way back to
it. While set to "system" the app keeps listening, so it follows the OS
switching at sunset without a reload.
Stored in `localStorage`, not in the dataset: the dataset is the owner's budget,
which is exported, imported and schema-versioned, and importing a backup taken
on a desktop must not flip a phone to dark. Every storage access is wrapped —
`localStorage` **throws** on access in some configurations rather than
returning null, and a display preference must never be why the app fails to
boot.
A tiny inline script in `index.html` applies the saved theme before first
paint, so a dark-theme user never sees a flash of the light palette. It cannot
import the module (it has to run before any module loads), so it repeats the
key and the fallback logic; `src/ui/theme.test.ts` asserts the two agree.
## The regression this produced, and the guard it earned
Renaming `--rule` to `--budget-rule` silently killed the carry meter — the
month view's signature element. An undefined custom property inside
`linear-gradient()` does not warn, does not throw, and does not fall back: the
whole declaration is invalid, so the element paints nothing. It looked like a
missing feature rather than a bug, and no test noticed. Pixel-sampling a
screenshot found it.
`src/ui/cssTokens.test.ts` now fails if any TypeScript file references a custom
property `src/index.css` does not define. This is the second guard of its kind
in the repo, after `eventCapture.test.ts`, and for the same reason: the failure
is invisible in review and invisible in a green suite.
## Verification, and its limits
`scripts/screenshot.ts` drives headless Chrome over CDP, which is how any of
this was seen at all. Both themes were rendered with real data on every route
at desktop and mobile widths; the resolved tokens were read out of the browser;
the carry meter's presence was confirmed by pixel sampling rather than by eye.
Not verified, and not verifiable here: how it feels, real offline behaviour,
the install prompt, and typography on the owner's actual platform — this
container has no Inter installed for the fallback stack and its fontconfig maps
every generic family to a monospace face, so screenshots need a
`FONTCONFIG_FILE` override to be representative at all.
+176
View File
@@ -0,0 +1,176 @@
# Fast entry refinements — Design
**Date:** 2026-09-02
**Status:** Awaiting review
**Supersedes nothing.** Extends `2026-09-01-budget-app-design.md`; every invariant
in that document still holds.
## Purpose
Three changes to how purchases are entered, driven by using the app:
1. Entering many lines should be keyboard-driven. Today the row list puts a
*remove* button between every amount and the next description, so tabbing
costs twice the stops it should, and adding a row needs a mouse.
2. The app should be about monthly spending, not a daily log. Today every
purchase demands an exact date during entry.
3. A purchase should carry a note — longer context than its short label.
They are specified together because all three change the same two entry forms
and, in particular, the same tab order. Building them separately would mean
writing that tab order twice.
## Non-goals
Deliberately excluded. Each is a separate cycle.
- **Time-versioned allocation rules** (a rule effective from a month onward).
Architectural: it changes `allocationFor`, which the whole rollover fold runs
through, and needs the project's first schema migration. Specified separately.
- **Per-line splits and finance plans in fast entry.** A line needing either is
saved plain and edited from the month view.
- **Any schema migration.** Both model changes here are backward compatible by
construction; see "Data model".
## Decisions and why
### Dates: widen the field rather than restructure it
`Purchase.date` keeps the type `IsoDate`, but the string may now be either
`"YYYY-MM-DD"` or `"YYYY-MM"`. A purchase with no day belongs to its month.
Considered and rejected: splitting into `month: MonthId` plus `day?: number`.
That is the more honest model and would be the right call in a greenfield
design. It was rejected because it changes a type every layer depends on and
requires a migration executed in the browser against the user's only copy of
their financial data. The user-visible outcome is identical. If the model
becomes a problem, it is a safe refactor later, behind better coverage.
This works because `monthOf()` already parses both shapes: it matches the first
seven characters and validates the month is 01–12. Verified against the current
implementation before adopting this approach:
| input | result |
|---|---|
| `"2026-09-14"` | `"2026-09"` |
| `"2026-09"` | `"2026-09"` |
| `"2026-13"`, `"2026-00"`, `"2026-9"`, `"nonsense"` | throws |
The month-range guard added earlier therefore already protects the month-only
form. This behaviour is currently incidental and must be pinned by a test, since
the whole approach depends on it.
### Notes: an optional field, not a replacement
`description` stays the short scannable label the month list renders. `note` is
optional free text alongside it. Making it optional is what keeps this
migration-free: absent is a legal value for every existing purchase.
### A row holding only a note is not blank
Blank rows are ignored on save. A row with a note but no description or amount
is NOT blank: it surfaces a validation message asking for the missing fields.
Silently discarding something the user typed is worse than an error.
## Design
### Entry flow (fast entry)
- Row columns become **description → amount → note**. These are the only tab
stops in a row.
- The list always ends in exactly one blank row. Filling the last row appends a
fresh blank below it, so Tab flows into a new line with no special-casing —
and new rows appear on touch devices, which have no Tab key. The **Add line**
button is therefore removed.
- **Backspace** in an empty description field removes that row and moves focus
to the previous row's **note** field, which is that row's last tab stop. The
list never shrinks below one row: removing the only remaining row clears it
instead of deleting it.
- A **×** control fades in on row hover or focus for pointer and touch users. It
carries `tabIndex={-1}` so it is clickable but never a tab stop — restoring
the tab order is the point of this change. It removes a row under the same
rule as Backspace, including the never-below-one-row floor.
- The running line count and total stay.
### Dates
- Both entry forms default to the month being viewed, with **no day**.
- A specific day is set by a **"set exact date" link** beneath the month, which
reveals a native `<input type="date">` when clicked. It is a link rather than
an always-present field so the day is opt-in, and it is not in the row tab
order. Once a day is set, the field stays visible for that purchase.
- Display shows the day only when the stored value has one; otherwise the month.
- Sorting and grouping are unaffected: everything already derives the month via
`monthOf()`.
### Notes
- Present in the single purchase dialog and as the third bulk-row column.
- Rendered in the month view's purchase list when non-empty, subordinate to the
description.
- Never required.
## Data model
```ts
// src/domain/types.ts
/** "YYYY-MM-DD", or "YYYY-MM" when the purchase has no specific day. */
type IsoDate = string;
interface Purchase {
// ...unchanged...
/** Optional longer context, alongside the short `description` label. */
note?: string;
}
```
No `schemaVersion` bump. Both changes are supersets of what is already stored:
every existing purchase has a full date (still legal) and no note (legal).
## Validation
`parseDatasetJson` must accept a `Purchase.date` of either shape and continue to
reject anything else — including a month outside 01–12, which the existing
`MONTH_ID` tightening already guards for month ids and must now also guard for
the month-only date form.
Fast-entry validation mirrors `validatePurchase` in what it deliberately does
**not** reject: no rejection on magnitude, negative amounts (a refund is a
normal line), splits that do not sum, or going over budget.
## Testing
- `monthOf()` accepts `"2026-09"` as well as `"2026-09-14"`, and still throws on
a malformed or out-of-range value. This is currently incidental behaviour that
the date change depends on; the test makes it a guarantee.
- `note` round-trips through `toPurchase`/`fromPurchase` and through
`toPurchases` for bulk lines.
- Blank-row logic accounts for the third column. A row is blank only when
description, amount AND note are all empty. Each partially filled shape
produces a specific, row-numbered message:
- description, no amount -> `Line N: enter an amount other than zero.`
- amount, no description -> `Line N: give it a description.`
- note only -> both of the above, since it has neither.
- Appending behaviour: filling the last row yields exactly one new trailing
blank, never two.
- JSON import accepts both date shapes and still rejects garbage.
- The existing suite stays green, and the `eventCapture` guard covers the new
handlers.
Keyboard behaviour — Tab order, Backspace-to-remove, focus after append — cannot
be verified in this environment: there is no browser, and happy-dom does not
model focus and tab traversal faithfully. These require the user to confirm.
## Files
| File | Change |
|---|---|
| `src/domain/types.ts` | `note?`, `IsoDate` doc comment |
| `src/domain/months.test.ts` | pin month-only parsing |
| `src/ui/purchaseForm.ts` | `note` in the draft and both conversions |
| `src/ui/bulkEntry.ts` | `note` per line; blank-row rule; trailing-blank append |
| `src/ui/components/BulkLines.tsx` | third column, tab order, Backspace, hover × |
| `src/ui/components/PurchaseDialog.tsx` | note field; date de-emphasis |
| `src/export/json.ts` | accept both date shapes |
| `src/export/ods.ts` | date column renders either shape |
| `src/ui/routes/MonthRoute.tsx`, `PostMonthRoute.tsx` | show note; show day only when present |
+177
View File
@@ -0,0 +1,177 @@
# Month view
Sub-project 2 of the visual redesign, decomposed in
`2026-09-02-design-foundation-design.md`: *"Month view — income and totals,
post rows and carry meter, purchases grouped by date."* Date-grouped purchases
shipped ahead of the rest (`a159189`); this spec covers what was left open.
The owner's standing instruction for the redesign still applies — *"override
everything you want for this step. This needs to look good."* — so the three
forks below were put to them as concrete alternatives rather than as open
questions. All three were answered with the recommended option.
## The diagnosis
Three problems, found by looking at the rendered page in Chrome at 1280px and
390px rather than by reading the code.
**The totals block was four peer figures with no relationship between them.**
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 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
signature element, and it was rendering 21px below its own row and 22px above
the next one — visually a divider, not a property of a post. There was also no
legend anywhere, so its four segments were unreadable to anyone who had not
read `PostTable.tsx`.
**Phone width was broken, and nothing recorded it.** The five-column table
overflowed its container at 390px: *Spent* and *Remaining*, the two columns
that answer the app's two questions, were off-screen behind a horizontal
scroll, and every figure wrapped its currency onto a second line
("+4,219.61 / DKK"). This is the width at which a grocery trip actually gets
entered.
## The month header
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 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.
2. **Allocated and Unallocated**, with the allocation meter beneath them and
"N% of income allocated" beside it.
3. **Spent**, under a hairline, with the month-level overspend answer beside
it.
### The allocation meter scales to `max(income, allocated)`
Not to income. Over-budget is always allowed (CLAUDE.md), and allocating past
income is the ordinary way that shows up here, so the meter has to render it
rather than cap at a full bar: the overflow past the income mark paints
`--overspend`, and the label reads past 100%. Capping would make
over-allocation look exactly like allocating income to the last krone.
Edge cases, all of them reachable:
- income ≤ 0 with allocation > 0 — no denominator, so the percentage is
omitted and the whole track is overflow.
- both zero — an empty track, no percentage.
- allocation ≤ 0 — an empty track; a fixed rule may be negative, and no
percentage can be below zero-length.
### "1 post overspent" is text, not a link
The Posts table is ~40px below this line. A control that scrolls the page by
less than a viewport is noise, so the line states the answer and stops.
It renders in both directions — *"no posts overspent"* in muted ink, *"1 post
overspent"* in `--overspend` — because a line that appears and disappears
shifts the layout of the block above it, and because "no" is a real answer to
the question rather than an absence of one.
The count is added to `MonthViewModel` as `overspentCount` rather than derived
in the component. It is a count over rows the fold already produced, and
putting it in the domain means it is tested where the rest of the month's
arithmetic is tested, with the UI left dumb.
## Post rows: two markups, one per structure
Below `sm:` each post becomes a two-line block — name and Remaining large on
the first line, the meter full width, then carried / allocated / spent as one
small caption line. Nothing is hidden and nothing scrolls sideways.
This is implemented as two sibling markups, `hidden sm:table` and `sm:hidden`,
not as one reflowing grid.
**Rejected: a single CSS grid carrying `role="table"` and friends.** It
deduplicates the markup, which is the whole of its appeal. But at 390px the
layout genuinely is not a table any more — it is a list of two-line blocks —
and leaving table roles on it would describe a grid of rows and columns to a
screen reader that no longer exists visually. Keeping a real `<table>` at the
width where it is a table, and a real list at the width where it is a list,
means each structure is honest about itself. The cost is that the figures
appear twice in the DOM at any one width; at six to thirty posts that is
nothing, and both markups read their cells from one shared `postRowCells`
helper, so the figures are still derived in exactly one place.
### The currency moves to the header
Every figure in this table is in the dataset's base currency, so repeating the
code on all thirty cells buys nothing and costs the line wrap that made the
phone layout unreadable. The `Section`'s hint carries "All figures in DKK"
once; the cells print `formatAmount`.
This is deliberately not a change to `formatMoney`, which is still right
everywhere an amount can be in some other currency — every purchase row, for
one, where a 27.50 EUR line sits among DKK.
## The carry meter becomes the row divider
One `<tbody>` per post, holding both the figures row and the meter row.
Multiple `<tbody>` elements in one table are valid, and this is what makes
`group-hover` tint a post's figures and its meter as one block; two loose
`<tr>` siblings cannot express that relationship at all.
The meter then sits tight under its own row with that row's hairline removed,
so the meter *is* the divider, and the whitespace moves below it. 3px to 4px.
The ambiguity was never about the meter's design — it was that the gaps above
and below it were within a pixel of each other.
A legend sits beside the hint, once for the table: four swatches, carried in /
spent / unspent / over. The meter itself stays `aria-hidden`; the four figures
beside it carry the same information as text, and a screen reader reading a
gradient twice is worse than not reading it.
Note what the meter still does not show: a *negative* carried-in balance. The
surplus segment is `max(carriedIn, 0)`, so Transport carrying −1,600 forward
shows a meter that starts at the allocation, and only the red Carried-in figure
says why the post is behind. That is existing, documented behaviour and this
sub-project does not change it.
## Where the code goes
The two meters are the same idea rendered at two scales, so the math comes out
of the component into `src/ui/meterSegments.ts` — pure, no DOM:
- `carryMeterSegments(figures)` — the four rollover segments.
- `allocationMeterSegments(income, allocated)` — the header's two or three.
- `gradientFor(segments)` — the hard-stopped `linear-gradient` string.
A thin `<Meter segments>` renders it. The token names stay in TypeScript
source, which is what keeps `src/ui/cssTokens.test.ts` guarding them — that
test exists because renaming `--rule` silently made this exact element paint
nothing, and moving the gradient into a `.css` file would have moved it out of
the guard's reach.
## Testing
- `src/ui/meterSegments.test.ts` (new): zero scale, negative carried-in,
charges past allocation, over-allocation, income 0 against allocation > 0,
and the two invariants that hold for every input — stops ascend, and every
stop lands within [0, 100].
- `src/domain/views.test.ts`: `overspentCount`, including that a post at
exactly zero is not counted.
- `src/cssPairs.test.ts` and `src/ui/cssTokens.test.ts` keep guarding the
tokens; no new pair is introduced.
- Both themes at 1280px and 390px through `scripts/screenshot.ts`.
## Not changed
No new validation anywhere: over-allocation still renders, negative figures
still render, and nothing here can block a write. `src/domain/` changes only by
gaining the count. The purchases list, the rule-from-month editor and the month
`<header>` are untouched.
## Verification limits
A screenshot proves the layout exists and is not broken. It cannot say the
month view now reads well, and this container's fonts are not the owner's
fonts. Whether the header answers the two questions faster than four peer
figures did is a judgement only the owner can make.
+223
View File
@@ -0,0 +1,223 @@
# Time-versioned allocation rules — Design
**Date:** 2026-09-02
**Status:** Awaiting review
**Extends** `2026-09-01-budget-app-design.md`. Every invariant there still holds,
with one deliberate replacement noted below.
## Purpose
A post's allocation changes over time: Food is 10% from April, then 15% from
July. Today a post has exactly one `standingRule` covering all of history, plus
a per-month override. Expressing "15% from July onward" currently means editing
every month individually, and the fact that it was a deliberate change is lost.
Rules become a dated series. A version takes effect in its month and continues
until the next version replaces it.
This model was offered during the original design and declined in favour of the
simpler standing rule. It is being adopted now because using the app showed the
simpler model does not survive contact with a budget that evolves.
## Non-goals
- **Versioning anything else.** Post name, currency and archived status stay
single-valued. Only the allocation rule is time-varying.
- **Retroactive recalculation prompts.** Adding a version dated in the past
changes past allocations, and therefore every later balance, because the
rollover fold is a left fold. That is correct and intended; the app does not
warn about it beyond showing the new numbers.
- **Reworking the fold.** `allocationFor` keeps its signature; only its
internals change.
## Decisions and why
### Dated series AND per-month overrides both survive
They mean different things, and collapsing them loses information:
- A **version** is a permanent change from month X onward: "Food is 15% from
July."
- An **override** is a one-month exception that leaves the series intact:
"December only, 3000 for Christmas."
Expressing a one-month exception as versions needs two entries — change, then
change back — and forgetting the second silently makes an exception permanent.
### No rule yet means an allocation of zero
If a post's earliest version starts in 2026-04, its allocation for every month
before that is 0. The post genuinely had no budget then. Spending in those
months still records and shows as overspend against a zero envelope, so nothing
is concealed.
Rejected: applying the earliest rule backwards. It invents budget for months the
user never budgeted, and the rollover fold would then carry that fictional
surplus forward into real months.
### The series lives on the post
`Post.rules` rather than a top-level `Dataset.ruleVersions` collection. The
fold reads a post's rule for every post in every month, so a top-level
collection would mean filtering by `postId` on the hottest path in the app, and
would separate a rule from the thing it governs when archiving or exporting.
Rejected: keeping `standingRule` and adding a map of later changes. It cannot
express "no rule yet" — a standing rule always exists — so it contradicts the
zero-before-start decision, and leaves two concepts describing one thing.
## Domain model
```ts
interface RuleVersion {
/** The rule takes effect in this month and continues until the next version. */
from: MonthId;
rule: Rule;
}
interface Post {
// ...unchanged...
rules: RuleVersion[];
}
```
`Post.standingRule` is **removed**, replaced by `rules`.
### Invariants
- `rules` is sorted ascending by `from`.
- **At most one version per month.** Setting a rule for a month that already has
one replaces it; two versions sharing a `from` would make the effective rule
ambiguous.
- An empty `rules` array is legal and means the post has never been budgeted.
- A `from` earlier than `settings.foldStartMonth` is legal but inert: the fold
never evaluates months before its start, so such a version simply has no
effect until the fold start moves earlier.
**A deliberate difference between new and migrated data.** Seeded posts start
with `rules: []` — a new user has genuinely not budgeted anything yet. Migrated
posts get one version carrying their existing rule, which for untouched seed
data is a zero fixed amount. So a fresh install shows "not budgeted" where a
migrated install shows an explicit 0. Those were indistinguishable under the old
model and are now different states; keeping them distinct is correct, because a
migrated user really did have a rule and a new user really does not.
### Resolution
`ruleAt(post, monthId): RuleVersion | null` returns the last version whose
`from <= monthId`, or `null` when none applies.
`allocationFor` resolves in strict precedence:
1. `month.ruleOverrides[postId]` — one-month exception, wins outright
2. `ruleAt(post, monthId)` — the effective version
3. neither -> **0**
`allocationFor` keeps its current signature, so `foldBalances`, the view models
and the ODS export are untouched.
`isOverridden` keeps its current meaning — an explicit one-month exception — and
continues to drive the month view's badge.
## Migration: schemaVersion 1 -> 2
```
{ standingRule } -> { rules: [{ from: settings.foldStartMonth, rule: standingRule }] }
```
This is the project's first real migration. It is a pure shape change with no
arithmetic, and it is **behaviour-preserving by construction**: today's standing
rule already applies from the fold start onward, so every month resolves to the
allocation it resolved to before.
Existing `Month.ruleOverrides` are untouched.
The migration runs in the browser against the user's only copy of their data.
Two things contain the risk: `migrate()` already refuses to open data written by
a newer build, so a half-upgraded state cannot silently corrupt; and the
behaviour-preserving property is asserted by test rather than assumed.
## UI
Two editing surfaces, both writing through the same `setRuleFrom` action, so
they cannot drift out of step with each other.
### Settings — the history editor
Each post row shows the rule **in effect for the current calendar month**
(Settings has no month context of its own), with a control
opening a chronological editor: a list of `from` month + rule, with add and
remove. A post with no rules shows "not budgeted" rather than a zero rule, since
those are different states.
Adding a version for a month that already has one replaces it, and the editor
says so rather than silently keeping the last write.
### Month view — change from here
Each post row gains a control that creates a version starting **that** month,
because the natural moment to decide "15% from July" is while looking at July.
It writes the same `setRuleFrom` action the history editor uses.
It opens pre-filled with the rule currently in effect for that month, so the
common act of nudging 10% to 15% is an edit rather than re-entry. If the post
has no rule yet, it opens empty.
The month view distinguishes the two kinds of change: an override keeps its
existing badge; a month where a version *begins* is marked separately, so a step
in the numbers has a visible cause. A month can carry both markers at once — a
version starting in a month the user also overrode is unusual but legal, and
showing only one would hide the reason the number is what it is. The override
still wins the allocation, per the precedence above.
## Actions
- `setRuleFrom(draft, postId, from: MonthId, rule: Rule)` — inserts or replaces
the version for that month, keeping `rules` sorted. Rounds the rule at the
mutation boundary like every other money-shaped write.
- `removeRuleFrom(draft, postId, from: MonthId)` — deletes that version. Removing
the earliest one means earlier months resolve to 0.
## Validation
`parseDatasetJson` must, for each post: accept a `rules` array; reject a
malformed or out-of-range `from` month using the existing tightened month
pattern; and reject duplicate `from` months, since the resulting effective rule
would be ambiguous.
Over-budget remains always permitted: no rule value is rejected for magnitude,
and percentages above 100% stay legal.
## Testing
- `ruleAt`: before the first version -> `null`; exactly on a version's month;
between two versions; after the last.
- `allocationFor`: no rule -> 0; an override beats the effective version; a
version applies from its month onward.
- **The fold across a rule change** — 10% through June, 15% from July — asserting
the allocation steps in July and that the rollover carries correctly across the
boundary. This is the test that proves the feature, because the fold is what
every view reads.
- **The migration preserves behaviour.** Build a v1 dataset, migrate it, and
assert *every month's allocation is identical before and after*. A shape-only
test would pass while balances moved; this is the property that matters.
- JSON import accepts v2, migrates v1, and rejects duplicate `from` months.
- `setRuleFrom` replaces rather than appends for a month that already has a
version, and keeps the array sorted.
- Archived posts still resolve rules, since they still fold.
Not covered: anything visual in either editing surface, and the real IndexedDB
upgrade path. Both need a browser.
## Files
| File | Change |
|---|---|
| `src/domain/types.ts` | `RuleVersion`; `Post.rules` replaces `standingRule` |
| `src/domain/allocation.ts` | `ruleAt`; precedence in `allocationFor` |
| `src/domain/seed.ts` | seed posts start with empty `rules`; `SCHEMA_VERSION` 2 |
| `src/store/migrations.ts` | the v1 -> v2 migration |
| `src/store/actions.ts` | `setRuleFrom`, `removeRuleFrom`; `addPost`/`updatePost` |
| `src/export/json.ts` | validate `rules`, reject duplicate months |
| `src/ui/routes/settings/PostsSection.tsx` | history editor |
| `src/ui/routes/MonthRoute.tsx`, `src/ui/components/PostTable.tsx` | change-from-here control; version-starts-here marker |