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
+319
@@ -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
@@ -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
@@ -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 |
|
||||
Executable
+177
@@ -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
@@ -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 |
|
||||
Reference in new issue
Block a user