Merge main into the anchored model

Main moved on by 27 commits since this branch forked, including three schema
steps of its own (budget folders, placement, nested bills). Resolutions worth
knowing:

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-30 16:51:17 +02:00
co-authored by Claude Opus 5.5
89 changed files with 4023 additions and 651 deletions
+96
View File
@@ -0,0 +1,96 @@
name: Continuous Integration
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
env:
REGISTRY: gitea.lucaswinther.info
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bunx tsc --noEmit
- name: Run test suite
run: bun test
- name: Build app
run: bun run build
docker-build-and-push:
runs-on: ubuntu-latest
needs: [lint-and-test]
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Gitea's job token cannot publish packages; use a package-write PAT.
- name: Log in to Gitea Container Registry
if: gitea.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ gitea.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=sha-
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
build-args: |
BUILD_VERSION=${{ gitea.sha }}
BUILD_REPOSITORY=https://gitea.lucaswinther.info/${{ gitea.repository }}
push: ${{ gitea.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Pull and smoke-test published image
if: gitea.event_name != 'pull_request'
env:
PUBLISHED_TAGS: ${{ steps.meta.outputs.tags }}
run: |
image="$(printf '%s\n' "$PUBLISHED_TAGS" | head -n 1)"
docker pull "$image"
docker run -d --rm --name budget2-ci "$image"
trap 'docker stop budget2-ci' EXIT
docker exec budget2-ci bun -e '
const base = "http://127.0.0.1:3000";
let ready = false;
for (let attempt = 0; attempt < 30; attempt++) {
try {
const response = await fetch(base + "/");
if (response.ok && (await response.text()).includes("Budget")) {
ready = true;
break;
}
} catch {}
await Bun.sleep(1000);
}
if (!ready) throw new Error("App did not serve its HTML");
for (const path of ["/sw.js", "/manifest.webmanifest"]) {
const response = await fetch(base + path);
if (!response.ok) throw new Error(`${path} returned ${response.status}`);
}
'
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t budget2 .
- run: docker build --build-arg BUILD_VERSION=${{ github.sha }} --build-arg BUILD_REPOSITORY=https://github.com/${{ github.repository }} -t budget2 .
- name: Serve the image and check it responds
run: |
docker run -d --rm -p 3000:3000 --name budget2 budget2
+1
View File
@@ -28,6 +28,7 @@ jobs:
env:
BUN_PUBLIC_BASE_PATH: /${{ github.event.repository.name }}/
BUILD_VERSION: ${{ github.sha }}
BUILD_REPOSITORY: https://github.com/${{ github.repository }}
run: bun run build
- name: Stop Pages from running the output through Jekyll
run: touch dist/.nojekyll
+2 -2
View File
@@ -142,8 +142,8 @@ ship twice — re-introducing one is a regression, not a style choice.
way to undo it from the UI. The rule itself is stated ONCE, in
`src/domain/granularity.ts` (`startDateNeedsDay`, `chargeDateNeedsDay`);
both boundaries, the purchase dialog's helpers and the Settings recurring
table all call it rather than restating it, and the 7 -> 8
migration expands the month-only charge dates schema 7 legitimately holds so
table all call it rather than restating it, and the 10 -> 11
migration expands the month-only charge dates earlier schemas legitimately hold so
that the migration's output satisfies the import boundary.
→ `src/domain/granularity.test.ts`, `src/domain/occurrences.test.ts`,
`src/store/actions.test.ts`, `src/store/migrations.test.ts`,
+2
View File
@@ -4,6 +4,8 @@ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
ARG BUN_PUBLIC_BASE_PATH=/
ARG BUILD_VERSION
ARG BUILD_REPOSITORY
ENV BUN_PUBLIC_BASE_PATH=$BUN_PUBLIC_BASE_PATH
RUN bun run build
+28 -2
View File
@@ -15,15 +15,35 @@ bun run build # production build into dist/
## Self-hosting with Docker
Gitea Actions builds the image on pull requests and publishes it to the Gitea
container registry after the checks pass on `main` and `v*` tags. Published
images are available as `gitea.lucaswinther.info/lucasw89/budget2.0:latest`,
`:main`, `:sha-<commit>`, and `:<version>` for version tags.
Add a repository Actions secret named `REGISTRY_TOKEN` containing a personal
access token with package write permission. The token must belong to the user
who pushes the commit or tag, since the workflow logs in as the triggering
actor. The Gitea runner must have access to a Docker daemon.
```bash
docker build -t budget2 .
docker pull gitea.lucaswinther.info/lucasw89/budget2.0:latest
docker run --rm -p 3000:3000 gitea.lucaswinther.info/lucasw89/budget2.0:latest
```
For a private package, run `docker login gitea.lucaswinther.info` first with a
Gitea account or token that has package read permission.
To build locally instead:
```bash
docker build --build-arg BUILD_VERSION="$(git rev-parse HEAD)" --build-arg BUILD_REPOSITORY=https://gitea.lucaswinther.info/lucasw89/budget2.0 -t budget2 .
docker run --rm -p 3000:3000 budget2
```
To serve from a subpath, build and run with `BUN_PUBLIC_BASE_PATH`:
```bash
docker build -t budget2 --build-arg BUN_PUBLIC_BASE_PATH=/budget/ .
docker build -t budget2 --build-arg BUN_PUBLIC_BASE_PATH=/budget/ --build-arg BUILD_VERSION="$(git rev-parse HEAD)" --build-arg BUILD_REPOSITORY=https://gitea.lucaswinther.info/lucasw89/budget2.0 .
docker run --rm -p 3000:3000 -e BUN_PUBLIC_BASE_PATH=/budget/ budget2
```
@@ -39,10 +59,16 @@ Everything is stored in this browser's IndexedDB. **Export regularly** from
Settings → Your data: the JSON export is both your backup and how you move your
budget to another device.
Use the header's Undo and Redo controls to reverse saved edits in the current
session. Ctrl/Cmd+Z undoes; Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y redoes. While typing
in a field, those shortcuts use the browser's text undo. History holds up to
50 edits and disappears when the page reloads.
Both destructive actions there — Import, and "Reset everything" — download a
backup of your current data before replacing it, and abort if that download
fails. Reset puts the app back to a brand-new install: the three starter posts,
the DKK/USD/EUR currency table, this month at zero income, no purchases.
Import and reset clear session undo history after they succeed.
## Known limitations
+26 -1
View File
@@ -10,7 +10,31 @@ import type { Currency, FxRate } from "./src/domain/types.ts";
const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH);
const outdir = path.join(process.cwd(), "dist");
const version = process.env.BUILD_VERSION ?? String(Date.now());
// A checkout knows its own revision. Docker excludes .git, so CI passes the
// same value through BUILD_VERSION as a build argument instead.
let checkoutCommit: string | undefined;
if (!process.env.BUILD_VERSION) {
try {
const candidate = (await Bun.$`git rev-parse --verify HEAD`.quiet().text()).trim();
if (/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(candidate)) {
const changes = (await Bun.$`git status --porcelain --untracked-files=normal`.quiet().text()).trim();
checkoutCommit = changes ? `${candidate}-dirty` : candidate;
}
} catch {
// Source archives and Docker contexts may have no Git metadata.
}
}
const version = process.env.BUILD_VERSION || checkoutCommit || String(Date.now());
let checkoutRepository: string | undefined;
if (!process.env.BUILD_REPOSITORY) {
try {
const candidate = (await Bun.$`git remote get-url origin`.quiet().text()).trim();
if (/^https?:\/\//.test(candidate)) checkoutRepository = candidate.replace(/\.git\/?$/, "").replace(/\/$/, "");
} catch {
// Source archives and Docker contexts may have no remote.
}
}
const sourceRepository = process.env.BUILD_REPOSITORY || checkoutRepository || "https://github.com/StereotypicalCat/budget2.0";
const FX_BASE: Currency = "DKK";
const FX_TARGETS: Currency[] = ["USD", "EUR", "GBP"];
@@ -108,6 +132,7 @@ const result = await Bun.build({
// build a bug report is about. Only defined here, never in dev, which is
// why it is read through a try/catch accessor.
"process.env.BUN_PUBLIC_BUILD_VERSION": JSON.stringify(version),
"process.env.BUN_PUBLIC_SOURCE_REPOSITORY": JSON.stringify(sourceRepository),
// Double-encoded on purpose: the inlined literal must be a JSON *string*
// for parseBakedRates to parse at runtime.
"process.env.BUN_PUBLIC_BAKED_FX_RATES": JSON.stringify(JSON.stringify(bakedFxRates)),
+42 -2
View File
@@ -58,8 +58,9 @@ remaining = carriedIn + allocation − charges (carried forward, monthly)
Unspent carries; overspend carries as debt. **Nothing else may recompute this.**
The month view, year matrix, summary, per-post views and the ODS export all
aggregate over `foldBalances` / `figuresFor`. A closing balance is read from
`byMonth[11].remaining`, never re-summed.
aggregate envelope figures over `foldBalances` / `figuresFor`. Overall Spent
also includes unassigned purchase slices, which have no envelope to fold. A
closing balance is read from `byMonth[11].remaining`, never re-summed.
A recurring cost adds a SECOND balance beside it, on its own carry:
@@ -117,6 +118,21 @@ uses on a first run, so "reset" and "a brand-new browser" cannot drift apart.
Both are preceded in the UI by a JSON backup download that must succeed before
the destructive write is attempted.
## Session undo and redo
The snapshot store keeps up to 50 prior saved datasets in memory. A changed
`mutate` adds one step after its write succeeds; an unchanged mutation adds
none. Undo and redo run through the same queue and persist their target dataset
before changing the visible snapshot or either history stack. A failed write
therefore leaves both the data and the available undo/redo actions intact.
Import and reset clear the stacks only after their write succeeds. They are
whole-dataset replacement flows with backup-first protection, not ordinary
undoable edits. History is not in `Dataset` or IndexedDB and vanishes on reload.
The header reads a stable history capability snapshot from the store to enable
its controls; keyboard shortcuts skip editable fields so browser text undo
still works.
## Money
A float, deliberately, with two rules that make it safe:
@@ -157,6 +173,20 @@ those parts*. Both split modes collapse to one code path per slice.
Applying a split's raw value per-slice looks simpler and passes every percentage
test — and charges a fixed "2000 to Video Games" in full on every slice.
A purchase with no post has an empty `splits` array. Its dated amount counts in
monthly, yearly and summary Spent totals, but charges no envelope and changes no
rollover balance. Recurring bills still require a destination post. JSON import
and ODS export preserve these unassigned purchases. In the month Budget tree,
they appear in the permanent, derived Unassigned section for each month their
purchase or finance plan touches. Creating a post from one assigns its full
amount to the new post without changing the purchase total or schedule.
A post with no applicable allocation rule has no overspend status, even if its
balance is negative. The balance remains visible and carries normally. An
explicit zero rule, including a zero monthly override, does have an overspend
status when its balance is negative. The views use the presence of a rule rather
than infer it from the numeric allocation, since both cases fold to zero.
## Things the app deliberately does NOT validate
Adding a "missing" guard here is a regression, not a fix:
@@ -220,6 +250,16 @@ is first and stored, every XML part parses, and numeric cells carry
behavioural one, because the bug it prevents depends on real browser render
timing that happy-dom does not reproduce.
## Budget placement
Folders organize posts and recurring bills in one sibling order. The pure
`budgetTree` selector combines month post rows with computed scheduled slots;
folder Bills due sums bill prices in the selected slot month, including
descendants, using confirmed purchase totals where available. Folder placement
is independent of bill split destinations. Only posts have allocations and
rollover balances. The month UI renders the same tree as a desktop table and a
mobile list, and keeps occurrence confirmation in the Expected band.
## Where the reasoning lives
- `AGENTS.md` — the rules, in enforceable one-line form, and which test fails
+12 -4
View File
@@ -18,7 +18,7 @@ record.
no household mode. → [`PRODUCT.md`](PRODUCT.md)
- **Schema migrations are mandatory, not optional.** `schemaVersion` plus
ordered migration functions applied on load, because IndexedDB holds the
user's only copy of their financial data. Now at version 8.
user's only copy of their financial data. Now at version 11.
→ [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md)
- **A destructive write downloads a backup first.** Import and reset both export
before replacing, and abort if the export fails. → commit `ac3d18a`
@@ -75,6 +75,14 @@ record.
- **Posts are archived, never deleted**, because purchases reference them.
Archived posts still fold. → [`ARCHITECTURE.md`](ARCHITECTURE.md)
## Budget folders
- **Folders contain posts and recurring bills in a mixed order.** Folders carry
scheduled Bills due totals; allocation and rollover remain on posts. Bill
placement and split destinations are independent. Rejected nested posts as
folders because that would mix schedule prices and envelope balances.
→ [`specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md`](specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md)
## Recurring costs
- **Two-stage confirmation, over projection-only or generation-only.** A
@@ -235,8 +243,8 @@ Not oversights. Each was decided against, and reopening one needs a reason.
amounts are all **allowed**. Adding validation is a regression.
→ [`AGENTS.md`](../AGENTS.md) §3
- The income field and the split editor's fixed-amount fields **do not parse a
currency**; only the purchase total does. → [`TODO.md`](TODO.md) §3
currency**; only the purchase total does. → [`TODO.md`](TODO.md) §5
- **No currency picker.** Adding a currency means typing code, name, symbol and
decimals by hand. → [`TODO.md`](TODO.md) §3
decimals by hand. → [`TODO.md`](TODO.md) §5
- **Symbol collisions resolve to the first currency defined with that symbol.**
Tested and documented rather than solved. → [`TODO.md`](TODO.md) §3
Tested and documented rather than solved. → [`TODO.md`](TODO.md) §5
+10
View File
@@ -54,12 +54,22 @@ Money is handled in DKK, USD and EUR, with DKK as the base.
Confirmed and in place:
- Nested budget folders organize posts, bills, and optionally individual
purchases. A single-post bill starts beneath that post but can be moved
independently. Folder Bills due reports scheduled bill prices for the selected
month; posts alone hold envelope balances. A purchase's separate row never
changes which posts its splits charge.
- Posts (envelopes) with monthly allocation, created and archived by the owner.
Posts are archived, never deleted, because purchases reference them.
- A post with no applicable allocation rule still records spending and shows its
balance, but is not labelled overspent. An explicit zero allocation is a
budget rule and can be overspent.
- Allocation is either a fixed amount or a percentage of that month's income.
- A purchase can be split across several posts, by percentage or by fixed
amounts, and can also be spread across future months as a payment plan. The
two compose.
- A purchase may have no post. It remains visible in its month and counts in
Spent, while leaving envelope balances unchanged.
- Per-month income, entered directly, varying month to month.
- Multi-currency (DKK/USD/EUR) against a user-maintained exchange-rate table,
with an optional on-demand fetch that fails soft and is never required.
+54 -40
View File
@@ -8,24 +8,29 @@ decisions behind it are in `docs/specs/` — one spec per sub-project,
each recording the alternatives that were rejected. Rules that outlived their
task moved to `AGENTS.md`.
**Current state:** merged to `main`. 758 tests passing,
`bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and
offline paths.
**Current state:** the anchored model is merged with budget folders and
placement. 870 tests passing across 61 files. `bunx tsc --noEmit` is clean;
`bun run --bun build` succeeds using the committed seed rates when live-fetch
is unavailable. The runtime override bypasses a broken local Bun shim.
**Schema is at version 8.** v1 -> v2 made allocation rules a dated series;
**Schema is at version 11.** v1 -> v2 made allocation rules a dated series;
v2 -> v3 moved currencies into the dataset; v3 -> v4 drops a stored rate-service
URL that can no longer work; v4 -> v5 adds sterling to an existing dataset;
v5 -> v6 collapses per-currency decimals into one dataset-wide setting; v6 -> v7
adds `Dataset.recurring`; v7 -> v8 moves a `lastCharge` cost off slots — it
strips `occurrenceDate` from every anchored charge, expands a month-only
`startDate` on an anchored `everyNMonths` cost to a day-granular one, and
expands a month-only `Purchase.date` on every anchored charge (an anchored
cost has no slots, so its charges' own dates are what the next due date is
measured from; the day comes from the `occurrenceDate` being stripped when
that names one in the same month, otherwise the 1st). Unlike the first six,
this step is NOT behaviour-preserving by construction: an anchored cost's
projected due dates genuinely change shape, because they no longer come from a
grid at all. It is still tested, the same as the others.
adds `Dataset.recurring`; v7 -> v8 adds budget folders and assigns existing
posts and recurring bills a deterministic mixed sibling order under Unfiled.
v8 -> v9 adds optional purchase and bill placement fields without moving old rows;
v9 -> v10 nests existing unfiled single-post bills under their post.
v10 -> v11 moves a `lastCharge` cost off slots — it strips `occurrenceDate`
from every anchored charge, expands a month-only `startDate` on an anchored
`everyNMonths` cost to a day-granular one, and expands a month-only
`Purchase.date` on every anchored charge (the day comes from the
`occurrenceDate` being stripped when that names one in the same month,
otherwise the 1st). The first nine preserve financial behaviour — not one
changes a stored figure. The tenth changes no stored figure either, but it is
NOT behaviour-preserving by construction: an anchored cost's projected due
dates genuinely change shape, because they no longer come from a grid at all.
All ten are tested.
A migration applies **on load, in memory**; it is written back on the next
mutation, not by `load()` itself. So a freshly migrated dataset shows the new
@@ -35,7 +40,36 @@ IndexedDB instead of the app.
---
## 1. Two things the recurring-cost model gets wrong, both known
## Optional monthly income forecast
Add a user-controlled toggle that predicts a month's income from earlier months.
For each earlier month, divide its recorded income by that month's number of
workdays; average those per-workday figures, then multiply by the target month's
workdays. Let the owner adjust the prediction when income changes, for example
after a raise, without rewriting recorded income or earlier months. Show
predicted and entered income distinctly, and keep budgeting based on entered
income unless the owner explicitly chooses otherwise. Before implementation,
settle whether workdays mean Monday–Friday only or exclude local holidays, and
whether an adjustment applies to one month or all future predictions.
## 1. Folder rename blur during an in-flight save
A folder name draft can remain unsaved if the owner changes it while a rename
save is in flight and then blurs the field before that save finishes. Preserve
the newer draft and ensure the blur requests a subsequent save after the pending
write completes. Cover the delayed-write sequence with a UI interaction test,
including the final persisted name and any surfaced write error.
## 2. Recurring split editor interaction coverage
`RecurringSplitEditor` has coverage for destination selection and dedicated post
creation, but its rendered split mode and value controls lack a focused
interaction test. Exercise the real controls to switch percentage/fixed mode
and edit a split value, then assert the resulting splits and remainder behavior.
Keep this scoped to the editor's user interaction; domain split calculations
already have their own tests.
## 3. Two things the recurring-cost model gets wrong, both known
Neither is a bug introduced by the anchored model. Both are recorded here
because they were found while reviewing it, and both are the kind of thing a
@@ -86,7 +120,7 @@ This follows from the spec and is not a defect, but it sits in tension with
AGENTS.md §1's "archiving must not retroactively remove past expected charges",
so it deserves saying out loud.
`anchoredOccurrences` emits occurrences from ONE anchor: the latest charge.
The anchored walk emits occurrences from ONE anchor: the latest charge.
Recording a charge therefore erases every earlier month's expectation for that
cost — there is nothing left in the dataset that says a charge was once
expected in July. Consequences, all by design:
@@ -114,7 +148,7 @@ slots stay put whether they were ever settled or not. The two anchorings differ
here on purpose (AGENTS.md §1's schedule invariant), and unifying them would
reintroduce the bug the anchored model exists to fix.
## 2. Visual redesign, sub-project 3 — Year, Summary, Post detail
## 4. Visual redesign, sub-project 3 — Year, Summary, Post detail
The owner's brief for the whole redesign: *"right now it looks a bit boring,
simple and ugly. We want sleek, modern, and nice to use."* On scoping: both
@@ -154,7 +188,7 @@ than re-decide** (spec: `2026-09-02-month-view-design.md`):
phone. `formatMoney` is still right wherever an amount can be in some other
currency — every purchase row, for one.
## 3. Visual redesign, sub-project 4 — dialogs and fast entry
## 5. Visual redesign, sub-project 4 — dialogs and fast entry
Untouched. Native `<select>` elements are still browser-default, and
`up` / `down` / `archive` in Settings are bare text buttons. Covers the purchase
@@ -164,7 +198,7 @@ There is already a spec for fast-entry *behaviour* —
`2026-09-02-fast-entry-refinements-design.md`. Read it before restyling, so the
visual work does not fight it.
## 4. Currency work deliberately left undone
## 6. Currency work deliberately left undone
All three were decided against at the time, not forgotten. Reopen only with a
reason.
@@ -180,27 +214,7 @@ reason.
Fine for one "$" currency; if the owner adds two, typing the code
disambiguates and nothing warns them. Tested, documented, not solved.
## 5. Nobody has independently reviewed the rule-history editing surfaces
Tasks 3-6 of the versioned-allocation-rules work — `setRuleFrom` /
`removeRuleFrom`, the JSON import validation, the Settings rule-history editor,
and the month view's "change from here" — were executed by hand rather than
dispatched, and no second pair of eyes has been over the diff.
Worth reading first: the four commit messages, `de128e3`, `1cb91d3`, `e3023d0`
and `f94e458`. They record why each surface works the way it does, including
three of the four places the original briefs were wrong — a number field that
could not be cleared, a component that kept the previous post's rule because it
was not keyed, and missing validation that let a NaN allocation into the fold.
(The fuller task log lived in a tool-managed ledger under `.superpowers/`, which
ignores its own contents and was never part of the repository. This file used to
send readers there, which worked only on the machine that wrote it.)
Both surfaces have since been seen working in a browser. What is missing is
review, not verification.
## 6. Still needs a human
## 7. Still needs a human
`scripts/screenshot.ts` drives headless Chrome, so this list is much shorter
than it was. Already seen and confirmed working: the carry meter (including red
@@ -0,0 +1,143 @@
# Budget Folders and Recurring Cost Placement Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let the owner place posts and editable recurring bills in nested folders, assign each bill to chosen posts, and see each folder's scheduled bill total on the month page.
**Architecture:** Schema v8 adds folders and a location for each post and bill. Pure selectors assemble the mixed tree and calculate scheduled prices from the existing occurrence projector. Store actions own placement and cycle safety; the UI consumes those actions without changing the envelope fold or confirmation ledger.
**Tech Stack:** Bun, TypeScript, React 19, React Router, IndexedDB snapshot store, Tailwind/shadcn components.
**Spec:** `docs/specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md`
## Global Constraints
- Bun only: `bun test`, `bunx tsc --noEmit`, `bun run build`.
- `src/domain/` stays pure and clock-free; scheduled occurrences remain computed, never stored.
- `settings.digits` controls all rounding; use `toBase` and `roundMoney` at money boundaries. Never use a missing FX fallback.
- A folder has no allocation rule, rollover, or purchase split identity. Posts alone hold balances.
- A folder's **Bills due** is scheduled occurrence prices in the selected month, including descendants; confirmed slots use the confirming purchase's actual total, even when `Purchase.date` is in another month.
- Keep purchase confirmation, `source.occurrenceDate`, post splits, and the single fold implementation intact.
- Imported datasets retain the pre-replacement backup gate. Store writes in UI use `useMutate`; event values are captured before its deferred callback.
- Spec and plan locations are `docs/specs/` and `docs/plans/`.
## File Map
- `src/domain/types.ts`, `seed.ts`: v8 folder types and fresh dataset shape.
- `src/store/migrations.ts`, `src/export/json.ts`: migration and import validation.
- `src/store/actions.ts`: folder creation, rename, and mixed sibling placement.
- `src/domain/budgetTree.ts`: pure mixed tree, bill slot totals, and month display data; `occurrences.ts` supplies a reusable next-slot query.
- `src/ui/routes/settings/BudgetFoldersSection.tsx`: folder and item placement controls.
- `src/ui/routes/settings/RecurringSection.tsx`, `src/ui/components/RecurringSplitEditor.tsx`: bill destinations and create-dedicated-post flow; a bill's month-page Edit link focuses its row in Settings.
- `src/ui/components/BudgetTree.tsx`, `src/ui/routes/MonthRoute.tsx`: grouped month presentation; reuse the post-row rendering from `PostTable` and `ExpectedBand` for confirmation.
## Review Focus
1. A bill assigned to a post in another folder must count in its own folder's Bills due and in that post's projected balance, once in each distinct measure. Pin in Task 3.
2. A confirmed slot paid in a different month must use its slot month for the folder total and its payment month for the post fold. Pin in Task 3.
3. A day/week bill can occur twice in one month, and an interval longer than 12 months can still have a correct next-slot label. Pin in Task 3.
4. A folder move to itself or a descendant must be rejected without changing the dataset; an imported orphan or cycle must also be rejected. Pin in Tasks 1 and 2.
5. A failed combined creation of a dedicated post and bill must leave neither saved, and an archived post must not be offered as a new destination. Pin in Task 4.
---
### Task 1: Schema v8 and import boundary
**Files:**
- Modify: `src/domain/types.ts`, `src/domain/seed.ts`, `src/store/migrations.ts`, `src/export/json.ts`
- Test: `src/domain/seed.test.ts`, `src/store/migrations.test.ts`, `src/export/json.test.ts`
- Modify: typed dataset fixtures reported by `bunx tsc --noEmit`
**Interfaces:**
- Produces: `type FolderId = string`; `interface BudgetFolder { id: FolderId; name: string; parentId: FolderId | null; order: number }`; required `folderId: FolderId | null` on `Post` and `RecurringCost`; required `Dataset.folders: BudgetFolder[]`; `SCHEMA_VERSION = 8`.
- [ ] **Step 1: Write failing tests.** Name cases `fresh dataset starts Unfiled`, `v7 migration preserves records and assigns mixed order`, and `import rejects invalid folder graph`. Assert `expect(data.folders).toEqual([])`, `expect(data.posts.every(p => p.folderId === null)).toBe(true)`, unchanged purchases/rules/splits, distinct root orders, and `expect(() => parseDatasetJson(json)).toThrow(ImportValidationError)` separately for duplicate IDs, missing parents, missing item folders, and cycles. A valid nested tree must parse.
- [ ] **Step 2: Run `bun test src/domain/seed.test.ts src/store/migrations.test.ts src/export/json.test.ts`.** Expected: the new v8 assertions fail.
- [ ] **Step 3: Add the schema and migration.** Append only a v7 -> v8 migration; do not rewrite old steps or import live defaults into a migration. Preserve old post order first, then old bill order. Validate folder references and cycles in `parseDatasetJson` after migration; accept `null` as Unfiled and require nonempty names and finite integer order values for folders and items.
- [ ] **Step 4: Run `bun test src/domain/seed.test.ts src/store/migrations.test.ts src/export/json.test.ts` and `bunx tsc --noEmit`.** Expected: pass. Update only fixture object literals the required new fields expose.
- [ ] **Step 5: Commit.** `git add src && git commit -m "feat: add budget folder schema and migration"`.
### Task 2: Folder actions and mixed sibling ordering
**Files:**
- Modify: `src/store/actions.ts`
- Test: `src/store/actions.test.ts`
**Interfaces:**
- Consumes: Task 1 `BudgetFolder`, `FolderId`, and item `folderId`.
- Produces: `type BudgetItemKey = { kind: "folder" | "post" | "recurring"; id: string }`; `addBudgetFolder(draft: Dataset, name: string, parentId: FolderId | null): BudgetFolder`; `renameBudgetFolder(draft: Dataset, id: FolderId, name: string): void`; `placeBudgetItem(draft: Dataset, key: BudgetItemKey, destination: FolderId | null, index: number): void`.
- [ ] **Step 1: Write failing action tests.** Name cases `mixed siblings reorder densely`, `invalid placement is atomic`, and `legacy move buttons use mixed order`. After placing a bill at index zero, assert `expect(siblings.map(item => item.order)).toEqual([0, 1, 2])` and `expect(siblings[0]!.id).toBe(bill.id)`. For each unknown destination, self/descendant move, nonexistent item, out-of-range index, and blank name, assert a throw and `expect(JSON.stringify(data)).toBe(before)`. Assert moving a folder to Unfiled works.
- [ ] **Step 2: Run `bun test src/store/actions.test.ts`.** Expected: the new action tests fail.
- [ ] **Step 3: Implement the three actions.** Resolve mixed siblings from folders, posts, and bills by `folderId`/`parentId`, splice a move into the requested destination, and renumber source and destination siblings. Keep `addPost` and `addRecurringCost` appending to the mixed Unfiled order; add an optional `folderId: FolderId | null = null` argument to `addPost` and use the cost's `folderId` when adding a bill. Delegate existing `movePost` and `moveRecurringCost` to `placeBudgetItem`; exclude `folderId` and `order` from their generic update actions so placement validation cannot be bypassed.
- [ ] **Step 4: Run `bun test src/store/actions.test.ts` and `bunx tsc --noEmit`.** Expected: pass.
- [ ] **Step 5: Commit.** `git add src/store && git commit -m "feat: manage nested budget folders"`.
### Task 3: Pure folder tree and scheduled prices
**Files:**
- Create: `src/domain/budgetTree.ts`, `src/domain/budgetTree.test.ts`
- Modify: `src/domain/occurrences.ts`
- Test: `src/domain/occurrences.test.ts`
**Interfaces:**
- Consumes: `monthView(dataset, monthId).rows`, `occurrencesByMonth`, `toBase`, Task 1 folder fields.
- Produces: `budgetTree(dataset: Dataset, monthId: MonthId, rows: readonly MonthPostRow[]): BudgetTreeView`, where `BudgetTreeView` has ordered `roots: BudgetNode[]` and `unfiled: BudgetNode[]`; `BudgetNode` is a folder node with `billsDue` and `children`, a post node with its `MonthPostRow`, or a bill node with its `RecurringCost`, selected-month occurrences, and `nextOccurrence`.
- Produces: `nextUnconfirmedOccurrenceOnOrAfter(dataset: Dataset, recurringId: RecurringCostId, monthId: MonthId): Occurrence | null`, sharing the recurrence walk with `occurrencesOf` so long intervals have no arbitrary 12-month cutoff; `null` means ended.
- [ ] **Step 1: Write failing selector tests.** Name cases `Housing counts rent once`, `nested totals follow bill placement`, `confirmed off-schedule slot uses actual price`, and `next slot exceeds preview horizon`. For Housing with a Rent post and bill, assert `expect(housing.billsDue).toBe(8000)`. For a 7,900 confirmation of a September slot paid in October, assert September Bills due is 7,900 and the October post charge is 7,900. Assert two 28-day slots contribute twice; FX at `digits: 0`, negative amounts, missing FX, empty/post-only folders, and a next occurrence beyond 12 months each get a direct expected value or throw assertion.
- [ ] **Step 2: Run `bun test src/domain/budgetTree.test.ts src/domain/occurrences.test.ts`.** Expected: new tests fail.
- [ ] **Step 3: Implement the selectors.** Use `occurrencesByMonth(dataset, monthId)` for selected-month totals, look up confirmed purchases by ID, call `toBase` for each occurrence, and `roundMoney` while summing. Build each item once from its location and shared order. Refactor the existing occurrence iterator only as needed to expose the next-slot query; keep its strict-advance throw and `lastCharge` behavior.
- [ ] **Step 4: Run `bun test src/domain/budgetTree.test.ts src/domain/occurrences.test.ts src/domain/charges.test.ts` and `bunx tsc --noEmit`.** Expected: pass.
- [ ] **Step 5: Commit.** `git add src/domain && git commit -m "feat: derive folder bill totals and mixed tree"`.
### Task 4: Choose or create bill destination posts
**Files:**
- Modify: `src/ui/routes/settings/RecurringSection.tsx`, `src/store/actions.ts`
- Create: `src/ui/components/RecurringSplitEditor.tsx`, `src/ui/components/RecurringSplitEditor.test.tsx`
- Test: `src/ui/routes/settings/RecurringSection.test.tsx`, `src/store/actions.test.ts`
**Interfaces:**
- Consumes: Task 2 `addPost`/`addRecurringCost` and existing `updateRecurringCost`/`useMutate`.
- Produces: `addRecurringCostWithPost(draft: Dataset, cost: Omit<RecurringCost, "id" | "order" | "splits" | "splitMode">, newPostName: string): RecurringCost` as one mutation for the dedicated-post path; it creates a 100% split to the new post. `RecurringSplitEditor` takes current mode/splits, active posts, and an `onChange` callback.
- [ ] **Step 1: Write failing tests.** Name cases `bill requires an explicit destination`, `bill can split across active posts`, and `dedicated post and bill save together`. Assert `expect(saved.splits[0]!.postId).toBe(chosen.id)` where `chosen` is not the first post; assert edited mode/values/one absorber and changed `monthView(...).rows` projected figures. Assert dedicated records share `folderId`; after a rejected `store.mutate`, assert neither exists. Assert archived posts are absent from select options and `30$` yields USD.
- [ ] **Step 2: Run `bun test src/ui/components/RecurringSplitEditor.test.tsx src/ui/routes/settings/RecurringSection.test.tsx src/store/actions.test.ts`.** Expected: the new tests fail.
- [ ] **Step 3: Implement the add and edit controls.** Replace the first-post default with explicit selection. Keep simple one-post assignment as the default form state, reveal multiple splits on demand, and use the existing `roundSplits` path. Capture input values before `mutate`; keep a failed dialog open with its error visible. Implement the combined action without a second persistence call. Give each existing bill row a stable `bill-<id>` anchor and focus target for the month-page Edit link.
- [ ] **Step 4: Run the focused tests and `bunx tsc --noEmit`.** Expected: pass.
- [ ] **Step 5: Commit.** `git add src/store src/ui && git commit -m "feat: choose recurring bill destinations"`.
### Task 5: Manage folders and item placement in Settings
**Files:**
- Create: `src/ui/routes/settings/BudgetFoldersSection.tsx`, `src/ui/routes/settings/BudgetFoldersSection.test.tsx`
- Modify: `src/ui/routes/SettingsRoute.tsx`, `src/ui/routes/settings/PostsSection.tsx`, `src/ui/routes/settings/RecurringSection.tsx`
**Interfaces:**
- Consumes: Task 2 actions and Task 3 tree ordering; emits no new domain model.
- [ ] **Step 1: Write failing UI tests.** Name cases `nested folder can be created and renamed`, `post and bill move independently`, and `failed move reports error`. After UI actions, assert `expect(post.folderId).toBe(housing.id)` and `expect(bill.folderId).toBe(utilities.id)`; moving a parent to Unfiled sets `parentId: null`. Assert descendant folders are absent from a parent move select, and a rejected save shows `[role="alert"]` while IDs and locations remain unchanged.
- [ ] **Step 2: Run `bun test src/ui/routes/settings/BudgetFoldersSection.test.tsx`.** Expected: new tests fail.
- [ ] **Step 3: Implement the hierarchy controls.** Use labeled native selects and buttons that work by keyboard. Wire `useMutate` for every store write. Keep post rule history and bill-rule editing accessible from their existing Settings sections; expose folder placement there or link clearly to the hierarchy section.
- [ ] **Step 4: Run focused settings tests and `bunx tsc --noEmit`.** Expected: pass.
- [ ] **Step 5: Commit.** `git add src/ui/routes && git commit -m "feat: organize posts and bills in settings"`.
### Task 6: Group the month page
**Files:**
- Create: `src/ui/components/BudgetTree.tsx`, `src/ui/components/BudgetTree.test.tsx`
- Modify: `src/ui/routes/MonthRoute.tsx`, `src/ui/components/PostTable.tsx`, `scripts/demo-data.ts`, `src/index.css` only if shared tokens are needed
**Interfaces:**
- Consumes: Task 3 `budgetTree` and Task 4 bill editor; keeps `ExpectedBand` as the sole confirmation queue.
- [ ] **Step 1: Write failing component tests.** Name cases `collapsed folder keeps Bills due`, `expanded tree preserves mixed order`, and `bill edit reaches its settings row`. With the repository's happy-dom/React `act` pattern, assert the Housing button's `getAttribute("aria-expanded")` is `"false"` while its parent text still includes `8,000` and `Bills due`. After activation, assert nested folder, Rent post, and Rent bill occur in sibling order; Unfiled shows migrated items. Assert bill Edit has `href="/settings#bill-<id>"`, an ended bill reads Ended, and a long-interval bill shows its actual next slot.
- [ ] **Step 2: Run `bun test src/ui/components/BudgetTree.test.tsx`.** Expected: new tests fail.
- [ ] **Step 3: Implement the month grouping.** Extract reusable desktop post `<tbody>` and mobile post `<li>` renderers from `PostTable`, then render mixed items in sibling order: folder headings, bill rows, and post rows. Keep valid desktop table semantics and a separate mobile list. Bill rows show amount, cadence, assigned posts, and next slot, with no balance figure; Edit links to the Settings bill anchor from Task 4. Keep the global due/overdue band above the tree and preserve rule editing. Update synthetic demo data with a nested folder, a dedicated Rent post/bill, and a shared small-bills post.
- [ ] **Step 4: Run `bun test src/ui/components/BudgetTree.test.tsx`, `bun test`, `bunx tsc --noEmit`, and `bun run build`.** Expected: all pass. Start `bun run dev` in a live session. Run `bun scripts/demo-data.ts /tmp/seed.js`, then `bun scripts/screenshot.ts http://localhost:3000/month/2026-09 /tmp/budget-folders-desktop.png --eval-file=/tmp/seed.js --reload --w=1280 --h=900` and the same command with `/tmp/budget-folders-mobile.png --w=390 --h=844`; inspect both PNGs and the script's console-error report. Capture dark theme too if the grouping adds theme-specific styles. Run the Impeccable mechanical detector once on changed UI targets after the final UI edits.
- [ ] **Step 5: Commit.** `git add src && git commit -m "feat: show budget folders on month page"`.
## Completion check
Run `git diff --check`, the full Bun suite, typecheck, and build after the final commit. Read `docs/ARCHITECTURE.md`, `docs/PRODUCT.md`, and `docs/DECISIONS.md` for any durable description that now needs updating; keep documentation changes scoped to the new hierarchy and its decision, then commit them. Report the tested behavior and browser checks separately from unverified offline, install, download, spreadsheet opening, and subjective design feel.
@@ -0,0 +1,12 @@
# Session undo and redo implementation
1. Extend snapshot-store tests with changed and unchanged edits, undo/redo
ordering, a new edit after undo, the 50-step bound, queued writes, failed
writes, import/reset boundaries, and session reload behavior. Run them red.
2. Add history state and queued undo/redo operations in `snapshot.ts`. Ensure
history changes only after persistence commits. Run the store tests green.
3. Add header controls and keyboard handling with an interaction test for
disabled states, error display, and native editable shortcuts. Run it red,
implement, then run it green.
4. Update architecture and user-facing documentation. Run `bun test`,
`bunx tsc --noEmit`, build, and inspect desktop/mobile browser captures.
@@ -0,0 +1,123 @@
# Budget folders and recurring cost placement
## Intent
The owner wants to organize a budget in folders and subfolders without making
every recurring bill an envelope. Rent may have its own post, while phone and
internet may share one post; the recurring rules remain individually editable.
The month page should show everyday posts such as Video Games, Events, and
Social cleanly alongside larger commitments. A bill's place in the folder tree
is independent of the post or posts its money uses.
Success means the owner can choose or create a destination post when adding a
bill, change that destination later, organize posts and bills together in nested
folders, and read the scheduled bill total of a folder for a selected month.
The post fold, two-stage bill confirmation, and purchase ledger remain the
sources of financial truth.
## Model
Add `Dataset.folders: BudgetFolder[]`, with each folder carrying `id`, `name`,
`parentId: FolderId | null`, and `order`. Add
`folderId: FolderId | null` to both `Post` and `RecurringCost`. `null` means
Unfiled, a visible virtual root group rather than a stored folder. Existing
records migrate with `folderId: null`; the v7 -> v8 migration adds an empty
folder array and assigns one deterministic sibling order to the existing posts
and bills. A new dataset starts with an empty folder array too.
Folders may nest to any depth. Sibling folders, posts, and bills share a
display order within their parent. The mutation layer assigns that order when
adding or moving an item and prevents a folder from becoming its own ancestor.
An item has one folder location; a recurring bill can still have splits into
several posts, including posts in other folders. Moving an item changes its
current organization across views, including past months, but never changes a
stored purchase, allocation rule, or computed post figure.
A folder is never an envelope: it has no currency, allocation rule, rollover,
or purchase split target. Only posts have those behaviors. Bill splits retain
their current `Split[]` format and remainder handling. No occurrence is stored
to support folders.
## Folder totals
On the month page, each folder heading shows **Bills due** in the dataset's
base currency. The figure is the sum of the prices of recurring occurrences
whose scheduled slot falls in the displayed month and whose bill is placed in
that folder or a descendant folder. A bill in a different folder contributes
there, even when it charges a post in this folder. Each occurrence contributes
once, so a 28-day bill can contribute twice in a month. An ended bill contributes
only before its end date. An empty folder shows zero; a folder containing only
posts also shows zero bills due.
For an unconfirmed occurrence, use its current expected amount. For a confirmed
occurrence, use the confirming purchase's actual total, because that is the
historical price paid. The slot month determines where the price appears;
`Purchase.date` continues to determine where the actual charge folds. Convert
each price to base using the same FX and `settings.digits` rules as the existing
money calculations, then round the sum at the boundary. A missing FX rate must
surface as an error, never a fallback. Negative prices remain allowed.
This heading is a schedule total, not a balance. It does not add post
allocations, remaining balances, or purchase charges to bill prices. Thus a
folder holding both a Rent post and its 8,000 rent bill shows 8,000 in **Bills
due**, not 16,000. Post balances remain visible on the post row. Parent folder
totals include each descendant bill exactly once.
## Month page and editing
Replace the flat post section with a tree of collapsible folders. Within an
open folder, posts keep their existing balance figures, meter, post links, and
allocation editing. Recurring bills appear as distinct rows showing their
price, cadence, assigned posts, and next scheduled occurrence (or Ended); an
Edit action opens the bill editor. The folder heading shows its Bills due total for this
month. The Unfiled group holds existing items and any newly created item not
placed in a folder. Collapsed folders still show their total so the first page
stays scannable. Keyboard users can expand and collapse folders, and the mobile
view uses a list rather than squeezing the desktop financial table.
Keep the due/overdue area as the fast reconciliation queue. It continues to
confirm occurrences into purchases using the existing rules, including
off-schedule payment dates. Bill rows in folders supply context and an editor,
not a second confirmation path or a second balance.
In the add-bill flow, select an existing destination post, create a dedicated
post (for example Rent), or expand a split editor to assign several posts.
Saving is disabled until the split has a destination. Creating a post and bill
is one store mutation so a failed write leaves neither behind. A newly created
dedicated post starts in the bill's selected folder, but either item can be
moved independently afterward. Editing an existing bill exposes the same split
controls. The bill amount remains a text field using `parseMoneyInput`.
Settings shows the same hierarchy for creating, renaming, reordering, moving,
and nesting folders and items. It also retains direct bill-rule editing and
post allocation history. Archived posts keep their fold history; ended bills
keep confirmations. The placement controls do not present folders as purchase
or allocation targets.
## Boundaries and verification
The domain provides a pure selector for the folder tree and scheduled totals,
using `occurrencesByMonth` rather than a second recurrence walk. It accepts the
displayed month from the caller and reads no clock. It never reimplements the
rollover fold. Store actions validate parent references, moves, cycles, and
split destinations. JSON import validates unique folder IDs, valid parent and
item references, and acyclic ancestry after migration. JSON export includes
the complete hierarchy; whole-dataset import keeps its existing backup gate.
Tests cover migration from v7, nested totals without double counting, multiple
occurrences in one month, confirmed actual prices and off-schedule dates, bills
assigned to posts in another folder, moves and cycle rejection, and import
validation. UI checks cover choosing or creating a post, editing a bill split,
Unfiled, collapse controls, and desktop/mobile month layouts. Browser captures
check layout and console errors; they do not establish whether the design feels
good in use.
## Alternatives considered
- **Labels on a flat list:** simpler storage but no folder/subfolder hierarchy
or useful collapse behavior for the first page.
- **Nested posts as folders:** would make parent items look budgetable and mix
bill prices with envelope balances, obscuring where money is actually counted.
- **One tree containing folders, posts, and bill rules:** selected. It makes
placement explicit while keeping bill assignment and envelope accounting
separate.
@@ -0,0 +1,11 @@
# Purchase and bill placement in budget folders
The owner can edit a purchase's post splits and also give it an independent row in the budget listing. That row may live in a selected folder or Unfiled. The purchase still appears in the monthly ledger, and its splits alone determine which envelope is charged. The budget row shows only in months where the purchase or an active finance-plan slice contributes. Moving the row changes organization, not the fold.
`Purchase.placement` is optional and carries a folder ID plus a sibling order. An absent placement means the purchase stays only in the monthly ledger. The edit dialog controls this field; the Settings folder tree can subsequently move or reorder a placed purchase. Folder deletion moves its placed purchases to the parent with the other children.
A new recurring cost assigned to one post defaults to a row nested beneath that post. A dedicated post and its bill are nested when created together. A bill split across several posts remains at folder level. An explicit folder choice in the add form takes precedence. `RecurringCost.parentPostId` records the nested display parent, while its splits remain the financial destinations. A nested bill follows the post when the post moves. Settings can move the bill to another post or to a folder without changing its splits or confirmations.
Posts with nested bills have a disclosure control on the month page. They start collapsed, like folders, and expanding the post reveals its bills beneath the balance row. The post balance remains visible in either state.
Schema 9 adds these optional fields. Its v8 to v9 migration only advances the version. Schema 10 then nests existing unfiled, single-post bills beneath their destination posts, so a preexisting budget gets the same layout as new bills. It leaves splits and confirmations unchanged. JSON import checks placement references, sibling order, and parent post references. Folder Bills due includes nested bills once. Purchase rows do not contribute to Bills due, since the figure is a schedule total.
@@ -0,0 +1,31 @@
# Session undo and redo
The owner needs to reverse an accidental saved edit without reconstructing a
budget from an export. History lasts for the current page session only; a reload
starts with the saved dataset and empty history.
## Behavior
- Every successful, changed `mutate` produces one undo step. Unchanged edits
produce none. The last 50 prior datasets are retained.
- Undo and redo restore complete datasets, including IDs and dated rules, by
writing through the store's existing serialization queue. A failed write
changes neither the displayed dataset nor either history stack.
- A new successful edit after undo clears redo. A failed edit leaves redo
available.
- Import and reset are destructive whole-dataset operations. Their existing
backup-first flow remains mandatory. On success they clear both history
stacks; on failure they leave history intact. Neither can be undone with the
session controls.
- The header offers Undo and Redo buttons. Ctrl/Cmd+Z undoes and
Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y redoes when focus is outside an editable
control. Native text editing shortcuts remain native.
- A failed undo or redo shows its persistence error near the controls.
## Implementation boundary
History belongs to `src/store/snapshot.ts`, where edits already serialize and
only become visible after persistence succeeds. The UI reads stable capability
state from the same store and invokes its methods; no domain or dataset schema
change is needed. History is deliberately memory-only, so exports contain only
the current dataset and IndexedDB needs no upgrade.
+10
View File
@@ -25,6 +25,12 @@ const d = createSeedDataset("2026-01", FALLBACK_FX_RATES);
const [games, food, social] = d.posts as [typeof d.posts[0], typeof d.posts[0], typeof d.posts[0]];
const rent = actions.addPost(d, "Rent", "DKK");
const transport = actions.addPost(d, "Transport", "DKK");
const housing = actions.addBudgetFolder(d, "Housing", null);
const utilities = actions.addBudgetFolder(d, "Small bills", housing.id);
rent.folderId = housing.id;
const smallBills = actions.addPost(d, "Household bills", "DKK");
smallBills.folderId = utilities.id;
for (const name of ["Internet", "Phone"]) actions.addRecurringCost(d, { name, folderId: utilities.id, archived: false, amount: { amount: name === "Internet" ? 249 : 129, currency: "DKK" }, startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: smallBills.id, value: 100, absorbsRemainder: true }] });
const savings = actions.addPost(d, "Savings", "DKK");
for (let m = 1; m <= 9; m++) {
@@ -47,6 +53,7 @@ actions.setRuleOverride(d, "2026-09", social.id, { kind: "fixed", amount: { amou
// every combination at once.
const wowSub = actions.addRecurringCost(d, {
name: "World of Warcraft",
folderId: null,
archived: false,
amount: { amount: 13, currency: "USD" },
startDate: "2026-06-05",
@@ -57,6 +64,7 @@ const wowSub = actions.addRecurringCost(d, {
});
actions.addRecurringCost(d, {
name: "Rent",
folderId: housing.id,
archived: false,
amount: { amount: 8200, currency: "DKK" },
startDate: "2026-06",
@@ -67,6 +75,7 @@ actions.addRecurringCost(d, {
});
actions.addRecurringCost(d, {
name: "Veg box",
folderId: null,
archived: false,
amount: { amount: 175, currency: "DKK" },
startDate: "2026-06-06",
@@ -79,6 +88,7 @@ actions.addRecurringCost(d, {
// off-schedule payment moves the whole series — the phone-bill behaviour.
const carInsurance = actions.addRecurringCost(d, {
name: "Car insurance",
folderId: null,
archived: false,
amount: { amount: 349, currency: "DKK" },
startDate: "2026-08-14",
+3
View File
@@ -36,6 +36,7 @@ function post(overrides: Partial<Post> = {}): Post {
id: "p1",
name: "Food",
order: 0,
folderId: null,
archived: false,
currency: "DKK",
rules: [{ from: "2026-01", rule: fixed(400) }],
@@ -48,6 +49,7 @@ function dataset(overrides: Partial<Dataset> = {}): Dataset {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2, digits: DIGITS },
currencies: TEST_CURRENCIES,
fxRates: rates,
folders: [],
posts: [post()],
months: [
{ id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} },
@@ -191,6 +193,7 @@ describe("the fold across a rule change", () => {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-05", schemaVersion: 2, digits: DIGITS },
currencies: TEST_CURRENCIES,
fxRates: [],
folders: [],
posts: [
post({
rules: [
+189
View File
@@ -0,0 +1,189 @@
import { expect, test } from "bun:test";
import { budgetTree } from "./budgetTree.ts";
import { MissingRateError } from "./fx.ts";
import { createSeedDataset } from "./seed.ts";
import type { BudgetFolder, Dataset, Post, Purchase, RecurringCost } from "./types.ts";
import { monthView } from "./views.ts";
const post = (id: string, folderId: string | null, order = 0): Post => ({
id, name: id, order, folderId, archived: false, currency: "DKK", rules: [],
});
const folder = (id: string, parentId: string | null, order = 0): BudgetFolder => ({ id, name: id, parentId, order });
const bill = (id: string, folderId: string | null, amount = 8000, order = 1): RecurringCost => ({
id, name: id, order, folderId, archived: false,
amount: { amount, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
splitMode: "percent", splits: [{ postId: "rent", value: 100, absorbsRemainder: true }],
});
function dataset(): Dataset {
const data = createSeedDataset("2026-09");
data.posts = [];
data.months = [{ id: "2026-09", income: { amount: 0, currency: "DKK" }, ruleOverrides: {} }];
return data;
}
const tree = (data: Dataset, month = "2026-09") => budgetTree(data, month, monthView(data, month).rows);
test("Housing counts rent once", () => {
const data = dataset();
data.folders = [folder("housing", null)];
data.posts = [post("rent", "housing")];
data.recurring = [bill("rent-bill", "housing")];
const housing = tree(data).roots[0]!;
expect(housing.kind).toBe("folder");
if (housing.kind !== "folder") return;
expect(housing.billsDue).toBe(8000);
expect(housing.children.map(node => node.kind)).toEqual(["post", "bill"]);
expect(housing.children[1]!.kind).toBe("bill");
});
test("nested totals follow bill placement", () => {
const data = dataset();
data.folders = [folder("housing", null), folder("utilities", "housing")];
data.posts = [post("rent", null)];
data.recurring = [bill("phone", "utilities", 250)];
const housing = tree(data).roots[0]!;
expect(housing.kind).toBe("folder");
if (housing.kind !== "folder") return;
expect(housing.billsDue).toBe(250);
expect(housing.children[0]!.kind).toBe("folder");
const utilities = housing.children[0]!;
if (utilities.kind !== "folder") return;
expect(utilities.billsDue).toBe(250);
expect(tree(data).unfiled.map(node => node.kind)).toEqual(["post"]);
});
test("confirmed off-schedule slot uses actual price", () => {
const data = dataset();
data.folders = [folder("housing", null)];
data.posts = [post("rent", "housing")];
data.recurring = [bill("rent-bill", "housing")];
const purchase: Purchase = {
id: "paid", date: "2026-10-02", description: "Rent",
total: { amount: 7900, currency: "DKK" }, splitMode: "percent",
splits: [{ postId: "rent", value: 100, absorbsRemainder: true }],
schedule: null, source: { recurringId: "rent-bill", occurrenceDate: "2026-09" },
};
data.purchases = [purchase];
const housing = tree(data).roots[0]!;
if (housing.kind !== "folder") throw new Error("Missing housing folder");
expect(housing.billsDue).toBe(7900);
expect(monthView(data, "2026-10").rows[0]!.figures.charges).toBe(7900);
});
test("two 28-day slots contribute twice", () => {
const data = dataset();
data.folders = [folder("housing", null)];
data.posts = [post("rent", null)];
data.recurring = [bill("twice", "housing", 50)];
data.recurring[0]!.startDate = "2026-09-01";
data.recurring[0]!.recurrence = { kind: "everyNDays", n: 28 };
const housing = tree(data).roots[0]!;
if (housing.kind !== "folder") throw new Error("Missing housing folder");
expect(housing.billsDue).toBe(100);
expect(housing.children[0]!.kind).toBe("bill");
const node = housing.children[0]!;
if (node.kind === "bill") expect(node.occurrences.map(o => o.date)).toEqual(["2026-09-01", "2026-09-29"]);
});
test("FX rounds each price at dataset digits zero", () => {
const data = dataset();
data.settings.digits = 0;
data.folders = [folder("housing", null)];
data.posts = [post("rent", null)];
data.recurring = [bill("fx", "housing", 10.6)];
data.recurring[0]!.amount.currency = "EUR";
data.fxRates = [{ currency: "EUR", baseUnitsPerOne: 7.5, updatedAt: "2026-09-01", source: "manual" }];
const housing = tree(data).roots[0]!;
if (housing.kind !== "folder") throw new Error("Missing housing folder");
expect(housing.billsDue).toBe(80);
});
test("negative prices subtract from Bills due", () => {
const data = dataset();
data.folders = [folder("housing", null)];
data.posts = [post("rent", null)];
data.recurring = [bill("refund", "housing", -25)];
const housing = tree(data).roots[0]!;
if (housing.kind === "folder") expect(housing.billsDue).toBe(-25);
});
test("missing FX surfaces an error", () => {
const data = dataset();
data.folders = [folder("housing", null)];
data.posts = [post("rent", null)];
data.recurring = [bill("fx", "housing")];
data.recurring[0]!.amount.currency = "EUR";
expect(() => budgetTree(data, "2026-09", [])).toThrow(MissingRateError);
});
test("empty and post-only folders have zero Bills due", () => {
const data = dataset();
data.folders = [folder("empty", null), folder("post-only", null, 1)];
data.posts = [post("rent", "post-only")];
expect(tree(data).roots.map(node => node.kind === "folder" ? node.billsDue : NaN)).toEqual([0, 0]);
});
test("a placed purchase is its own row while its post still carries the charge", () => {
const data = dataset();
data.folders = [folder("housing", null)];
data.posts = [post("rent", "housing")];
data.purchases = [{ id: "paid", date: "2026-09", description: "Deposit", total: { amount: 100, currency: "DKK" }, splitMode: "percent", splits: [{ postId: "rent", value: 100, absorbsRemainder: true }], schedule: null, placement: { folderId: "housing", order: 1 } }];
const housing = tree(data).roots[0]!;
if (housing.kind !== "folder") throw new Error("Missing folder");
expect(housing.children.map(node => node.kind)).toEqual(["post", "purchase"]);
expect(monthView(data, "2026-09").rows[0]!.figures.charges).toBe(100);
expect(tree(data, "2026-10").roots[0]).toMatchObject({ kind: "folder", children: [{ kind: "post" }] });
});
test("unassigned purchases appear in one Budget group for their slice month", () => {
const data = dataset();
data.folders = [folder("housing", null)];
data.purchases = [
{ id: "loose", date: "2026-09", description: "Loose", total: { amount: 25, currency: "DKK" }, splitMode: "percent", splits: [], schedule: null },
{ id: "placed", date: "2026-09", description: "Placed", total: { amount: 40, currency: "DKK" }, splitMode: "percent", splits: [], schedule: null, placement: { folderId: "housing", order: 0 } },
];
const september = tree(data);
expect(september.unassigned.map(node => node.kind === "purchase" ? node.purchase.id : "other")).toEqual(["loose", "placed"]);
expect(september.roots[0]).toMatchObject({ kind: "folder", children: [] });
expect(tree(data, "2026-10").unassigned).toEqual([]);
});
test("a bill nested under its post follows that post into another folder", () => {
const data = dataset();
data.folders = [folder("housing", null), folder("other", null, 1)];
data.posts = [post("rent", "housing")];
data.recurring = [{ ...bill("rent-bill", null), parentPostId: "rent" }];
const first = tree(data).roots[0]!;
if (first.kind !== "folder") throw new Error("Missing folder");
expect(first.billsDue).toBe(8000);
expect(first.children[0]).toMatchObject({ kind: "post", children: [{ kind: "bill" }] });
data.posts[0]!.folderId = "other";
const second = tree(data).roots[1]!;
if (second.kind !== "folder") throw new Error("Missing folder");
expect(second.billsDue).toBe(8000);
});
test("an anchored bill's charges this month count toward Bills due, at their actual price", () => {
// An anchored cost has no confirmed occurrences — a charge is a Purchase, and
// the projection moves on to one cycle after it. Counting occurrences alone
// would read a phone plan charged twice in September as 0 due there.
const data = dataset();
data.folders = [folder("utilities", null)];
data.posts = [post("rent", null)];
data.recurring = [{ ...bill("phone", "utilities", 250), startDate: "2026-09-01", anchoring: "lastCharge" }];
const charge = (id: string, date: string, amount: number): Purchase => ({
id, date, description: "Phone", total: { amount, currency: "DKK" }, splitMode: "percent",
splits: [{ postId: "rent", value: 100, absorbsRemainder: true }], schedule: null,
source: { recurringId: "phone" },
});
data.purchases = [charge("a", "2026-09-05", 250), charge("b", "2026-09-20", 300)];
const utilities = tree(data).roots[0]!;
if (utilities.kind !== "folder") throw new Error("expected a folder");
expect(utilities.billsDue).toBe(550);
const phone = utilities.children[0]!;
if (phone.kind !== "bill") throw new Error("expected a bill");
expect(phone.nextOccurrence?.date).toBe("2026-10-20");
const october = tree(data, "2026-10").roots[0]!;
if (october.kind !== "folder") throw new Error("expected a folder");
expect(october.billsDue).toBe(250);
});
+122
View File
@@ -0,0 +1,122 @@
import { toBase } from "./fx.ts";
import { roundMoney } from "./money.ts";
import { sliceAmountForMonth } from "./charges.ts";
import { monthOf } from "./months.ts";
import {
chargesFor,
nextUnconfirmedOccurrenceOnOrAfter,
occurrencesByMonth,
type Occurrence,
} from "./occurrences.ts";
import type { BudgetFolder, Dataset, MonthId, Purchase, RecurringCost } from "./types.ts";
import type { MonthPostRow } from "./views.ts";
export type BudgetNode =
| { kind: "folder"; folder: BudgetFolder; billsDue: number; children: BudgetNode[] }
| { kind: "post"; row: MonthPostRow; children: BudgetNode[] }
| { kind: "purchase"; purchase: Purchase; amount: NonNullable<ReturnType<typeof sliceAmountForMonth>> }
| { kind: "bill"; cost: RecurringCost; occurrences: Occurrence[]; nextOccurrence: Occurrence | null };
export interface BudgetTreeView {
roots: BudgetNode[];
unfiled: BudgetNode[];
unassigned: BudgetNode[];
}
/** Pure month-specific placement and scheduled bill prices. */
export function budgetTree(
dataset: Dataset,
monthId: MonthId,
rows: readonly MonthPostRow[],
): BudgetTreeView {
const slots = occurrencesByMonth(dataset, monthId).get(monthId) ?? [];
const purchases = new Map(dataset.purchases.map(purchase => [purchase.id, purchase]));
const slotsByBill = new Map<string, Occurrence[]>();
for (const slot of slots) {
const found = slotsByBill.get(slot.recurringId);
if (found) found.push(slot);
else slotsByBill.set(slot.recurringId, [slot]);
}
type OrderedNode = { order: number; node: BudgetNode };
const byParent = new Map<string | null, OrderedNode[]>();
const add = (parent: string | null, order: number, node: BudgetNode): void => {
const found = byParent.get(parent);
if (found) found.push({ order, node });
else byParent.set(parent, [{ order, node }]);
};
for (const folder of dataset.folders) {
add(folder.parentId, folder.order, { kind: "folder", folder, billsDue: 0, children: [] });
}
for (const row of rows) {
add(row.post.folderId, row.post.order, { kind: "post", row, children: [] });
}
const postBills = new Map<string, BudgetNode[]>();
for (const cost of dataset.recurring) {
const node: BudgetNode = {
kind: "bill",
cost,
occurrences: slotsByBill.get(cost.id) ?? [],
nextOccurrence: nextUnconfirmedOccurrenceOnOrAfter(dataset, cost.id, monthId),
};
if (cost.parentPostId) {
const children = postBills.get(cost.parentPostId) ?? [];
children.push(node);
postBills.set(cost.parentPostId, children);
} else add(cost.folderId, cost.order, node);
}
const unassigned: BudgetNode[] = [];
for (const purchase of dataset.purchases) {
const amount = sliceAmountForMonth(purchase, monthId);
if (!amount) continue;
const node: BudgetNode = { kind: "purchase", purchase, amount };
if (purchase.splits.length === 0) unassigned.push(node);
else if (purchase.placement) add(purchase.placement.folderId, purchase.placement.order, node);
}
const digits = dataset.settings.digits;
const inBase = (price: Purchase["total"]): number =>
toBase(price, dataset.settings.baseCurrency, dataset.fxRates, digits);
// An anchored bill's charges are Purchases, never confirmed occurrences, so
// they are counted here by their own date; its occurrences are projections
// only. Without this, a charged anchored bill would read as nothing due.
const anchoredChargesDue = (cost: RecurringCost): number =>
cost.anchoring !== "lastCharge" ? 0 : chargesFor(dataset.purchases, cost.id)
.filter(charge => monthOf(charge.date) === monthId)
.reduce((sum, charge) => roundMoney(sum + inBase(charge.total), digits), 0);
const billDue = (cost: RecurringCost, occurrences: readonly Occurrence[]): number =>
occurrences.reduce((sum, occurrence) => {
const purchase = occurrence.confirmedBy ? purchases.get(occurrence.confirmedBy) : null;
if (occurrence.confirmedBy && !purchase) {
throw new Error(`Missing confirming purchase: ${occurrence.confirmedBy}`);
}
const price = purchase?.total ?? occurrence.amount;
return roundMoney(sum + inBase(price), digits);
}, anchoredChargesDue(cost));
const dueInNode = (node: BudgetNode): number => {
if (node.kind === "folder") return node.billsDue;
if (node.kind === "bill") return billDue(node.cost, node.occurrences);
if (node.kind === "post") return node.children.reduce((sum, child) => roundMoney(sum + dueInNode(child), digits), 0);
return 0;
};
const childrenOf = (parent: string | null): BudgetNode[] =>
(byParent.get(parent) ?? [])
.sort((a, b) => a.order - b.order)
.map(({ node }) => {
if (node.kind === "folder") {
node.children = childrenOf(node.folder.id);
node.billsDue = node.children.reduce((sum, child) => roundMoney(sum + dueInNode(child), digits), 0);
} else if (node.kind === "post") {
node.children = (postBills.get(node.row.post.id) ?? []).sort((a, b) => a.kind === "bill" && b.kind === "bill" ? a.cost.order - b.cost.order : 0);
}
return node;
});
const rootItems = childrenOf(null);
return {
roots: rootItems.filter(node => node.kind === "folder"),
unfiled: rootItems.filter(node => node.kind !== "folder"),
unassigned,
};
}
+4
View File
@@ -214,6 +214,7 @@ describe("chargesForMonth", () => {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS },
currencies: TEST_CURRENCIES,
fxRates: rates,
folders: [],
posts: [],
months: [],
purchases: [
@@ -233,6 +234,7 @@ describe("chargesForMonth", () => {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS },
currencies: TEST_CURRENCIES,
fxRates: rates,
folders: [],
posts: [],
months: [],
purchases: [],
@@ -251,6 +253,7 @@ describe("expectedForMonth", () => {
{ code: "USD", symbol: "$", name: "US dollar" },
],
fxRates: [{ currency: "USD", baseUnitsPerOne: 7, updatedAt: "2026-01-01", source: "manual" }],
folders: [],
posts: [],
months: [],
purchases,
@@ -262,6 +265,7 @@ describe("expectedForMonth", () => {
id: "r1",
name: "Rent",
order: 0,
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
+14
View File
@@ -125,6 +125,7 @@ export function chargesForPurchaseInMonth(
): Charge[] {
const slice = sliceAmountForMonth(purchase, monthId);
if (!slice) return [];
if (purchase.splits.length === 0) return [];
// Split proportions come from the WHOLE purchase, so a financed purchase
// divides each slice in the same ratio as the purchase as a whole — which is
@@ -140,6 +141,19 @@ export function chargesForPurchaseInMonth(
);
}
/** Spending with no envelope destination in one month, in base currency. */
export function looseChargesForMonth(dataset: Dataset, monthId: MonthId): number {
return dataset.purchases.reduce((sum, purchase) => {
if (purchase.splits.length !== 0) return sum;
const slice = sliceAmountForMonth(purchase, monthId);
if (!slice) return sum;
return roundMoney(
sum + toBase(slice, dataset.settings.baseCurrency, dataset.fxRates, dataset.settings.digits),
dataset.settings.digits,
);
}, 0);
}
/** Total charged per post in `monthId`, in base currency. */
export function chargesForMonth(
dataset: Dataset,
+4
View File
@@ -96,11 +96,13 @@ describe("the fold at zero decimals", () => {
fxRates: [
{ currency: "USD", baseUnitsPerOne: 151.234567, updatedAt: "2026-01-01", source: "manual" },
],
folders: [],
posts: [
{
id: "food",
name: "Food",
order: 0,
folderId: null,
archived: false,
currency: "JPY",
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 33.333 } }],
@@ -113,6 +115,7 @@ describe("the fold at zero decimals", () => {
id: "fun",
name: "Fun",
order: 1,
folderId: null,
archived: false,
currency: "JPY",
rules: [],
@@ -134,6 +137,7 @@ describe("the fold at zero decimals", () => {
id: "gym",
name: "Gym",
order: 0,
folderId: null,
archived: false,
amount: { amount: 7, currency: "USD" },
startDate: "2026-01",
+10 -3
View File
@@ -18,6 +18,7 @@ function post(id: string, fixed: number, archived = false): Post {
id,
name: id,
order: 0,
folderId: null,
archived,
currency: "DKK",
rules: [{ from: "2026-01", rule: { kind: "fixed", amount: { amount: fixed, currency: "DKK" } } }],
@@ -41,6 +42,7 @@ function dataset(posts: Post[], purchases: Purchase[]): Dataset {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS },
currencies: TEST_CURRENCIES,
fxRates: [],
folders: [],
posts,
months: [
{ id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} },
@@ -131,6 +133,7 @@ test("percentage allocations follow the month's income", () => {
id: "food",
name: "Food",
order: 0,
folderId: null,
archived: false,
currency: "DKK",
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 10 } }],
@@ -191,11 +194,13 @@ function datasetWithRent(): Dataset {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 },
currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }],
fxRates: [],
folders: [],
posts: [
{
id: "housing",
name: "Housing",
order: 0,
folderId: null,
archived: false,
currency: "DKK",
rules: [{ from: "2026-01", rule: { kind: "fixed", amount: { amount: 10000, currency: "DKK" } } }],
@@ -208,6 +213,7 @@ function datasetWithRent(): Dataset {
id: "rent",
name: "Rent",
order: 0,
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
@@ -294,11 +300,12 @@ describe("the projected track", () => {
*/
function datasetWithAnchoredPlan(): Dataset {
return {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 8, digits: 2 },
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 11, digits: 2 },
currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }],
folders: [],
fxRates: [],
posts: [
{
{ folderId: null,
id: "phone",
name: "Phone",
order: 0,
@@ -310,7 +317,7 @@ function datasetWithAnchoredPlan(): Dataset {
months: [],
purchases: [],
recurring: [
{
{ folderId: null,
id: "plan",
name: "Phone plan",
order: 0,
+1
View File
@@ -11,5 +11,6 @@ export * from "./charges.ts";
export * from "./occurrences.ts";
export * from "./fold.ts";
export * from "./views.ts";
export * from "./budgetTree.ts";
export * from "./seed.ts";
export * from "./plans.ts";
+42 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { occurrencesByMonth, occurrencesOf, stepFrom } from "./occurrences.ts";
import { nextUnconfirmedOccurrenceOnOrAfter, occurrencesByMonth, occurrencesOf, stepFrom } from "./occurrences.ts";
import type { Dataset, Purchase, RecurringCost } from "./types.ts";
function cost(overrides: Partial<RecurringCost> = {}): RecurringCost {
@@ -7,6 +7,7 @@ function cost(overrides: Partial<RecurringCost> = {}): RecurringCost {
id: "r1",
name: "Rent",
order: 0,
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
@@ -210,6 +211,7 @@ describe("occurrencesByMonth", () => {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 },
currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }],
fxRates: [],
folders: [],
posts: [],
months: [],
purchases,
@@ -440,3 +442,42 @@ describe("the calendar walk is unchanged", () => {
expect(found.map((o) => o.confirmedBy)).toEqual(["p1", "p2", null]);
});
});
test("next slot exceeds preview horizon", () => {
const data: Dataset = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 11, digits: 2 },
currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }],
fxRates: [], folders: [], posts: [], months: [], purchases: [],
recurring: [cost({ startDate: "2026-01", recurrence: { kind: "everyNMonths", n: 24 } })],
};
expect(nextUnconfirmedOccurrenceOnOrAfter(data, "r1", "2026-02")?.date).toBe("2028-01");
data.recurring[0]!.endedFrom = "2027-01";
expect(nextUnconfirmedOccurrenceOnOrAfter(data, "r1", "2026-02")).toBeNull();
});
test("next-slot lookup rejects a recurrence that does not advance", () => {
const data: Dataset = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 11, digits: 2 },
currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }],
fxRates: [], folders: [], posts: [], months: [], purchases: [],
recurring: [cost({ startDate: "2026-01-01", recurrence: { kind: "everyNDays", n: 0 } })],
};
expect(() => nextUnconfirmedOccurrenceOnOrAfter(data, "r1", "2026-01")).toThrow(/did not advance/);
});
test("the next-due lookup follows the anchored walk, not the grid", () => {
// Two charges in September: the next due date is one cycle after the LATER
// one. The grid would have treated them as two settled slots and answered
// November — the bug the anchored model exists to fix.
const charge = (id: string, date: string): Purchase => ({ ...confirmation("unused", date, id), source: { recurringId: "r1" } });
const data: Dataset = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 11, digits: 2 },
currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }],
fxRates: [], folders: [], posts: [], months: [],
purchases: [charge("a", "2026-09-05"), charge("b", "2026-09-20")],
recurring: [cost({ startDate: "2026-09-01", anchoring: "lastCharge" })],
};
expect(nextUnconfirmedOccurrenceOnOrAfter(data, "r1", "2026-09")?.date).toBe("2026-10-20");
data.recurring[0]!.endedFrom = "2026-10-01";
expect(nextUnconfirmedOccurrenceOnOrAfter(data, "r1", "2026-09")).toBeNull();
});
+49 -24
View File
@@ -182,6 +182,15 @@ export function occurrencesOf(
purchases: readonly Purchase[],
upToMonth: MonthId,
): Occurrence[] {
return [...walkFor(cost, purchases, upToMonth)];
}
/** Picks the walk for the cost's anchoring. Unbounded when `upToMonth` is absent. */
function walkFor(
cost: RecurringCost,
purchases: readonly Purchase[],
upToMonth?: MonthId,
): Generator<Occurrence> {
return cost.anchoring === "lastCharge"
? anchoredOccurrences(cost, purchases, upToMonth)
: gridOccurrences(cost, confirmationsFor(purchases, cost.id), upToMonth);
@@ -191,11 +200,11 @@ export function occurrencesOf(
* The `lastCharge` walk. No slots, no rebasing rule to apply — just "one cycle
* after the latest charge, or `startDate` if there has never been one".
*/
function anchoredOccurrences(
function* anchoredOccurrences(
cost: RecurringCost,
purchases: readonly Purchase[],
upToMonth: MonthId,
): Occurrence[] {
upToMonth?: MonthId,
): Generator<Occurrence> {
const charges = chargesFor(purchases, cost.id);
const latest = charges.length > 0 ? charges[charges.length - 1]!.date : null;
@@ -224,18 +233,18 @@ function anchoredOccurrences(
let cursor = anchor === null ? cost.startDate : stepFrom(anchor, cost.recurrence, cost.anchoring);
const found: Occurrence[] = [];
while (compareMonths(monthOf(cursor), upToMonth) <= 0) {
while (true) {
if (upToMonth && compareMonths(monthOf(cursor), upToMonth) > 0) break;
if (cost.endedFrom && !isBefore(cursor, cost.endedFrom)) break;
found.push({
yield {
recurringId: cost.id,
date: cursor,
amount: cost.amount,
// Always null: an anchored occurrence is a projection, and a charge that
// happened is a Purchase counted in `charges`, never an occurrence here.
confirmedBy: null,
});
};
const next = stepFrom(cursor, cost.recurrence, cost.anchoring);
// Defensive. Stepping forward from a real date with `n >= 1` always
@@ -249,7 +258,6 @@ function anchoredOccurrences(
}
cursor = next;
}
return found;
}
/**
@@ -259,33 +267,27 @@ function anchoredOccurrences(
* Unlike the anchored walk, a confirmation never moves this cursor — paying
* September AND October's rent correctly leaves nothing due until November,
* which is exactly why `calendar` was never the buggy case.
*
* Unbounded when `upToMonth` is absent, for the next-slot lookup.
*/
function gridOccurrences(
function* gridOccurrences(
cost: RecurringCost,
confirmations: ReadonlyMap<IsoDate, Purchase>,
upToMonth: MonthId,
): Occurrence[] {
const found: Occurrence[] = [];
upToMonth?: MonthId,
): Generator<Occurrence> {
// A monthly cost is month-granular throughout, even if its start date was
// typed with a day. Rent occurs IN a month.
let cursor =
cost.recurrence.kind === "everyNMonths" ? monthOf(cost.startDate) : cost.startDate;
while (compareMonths(monthOf(cursor), upToMonth) <= 0) {
while (true) {
if (upToMonth && compareMonths(monthOf(cursor), upToMonth) > 0) break;
if (cost.endedFrom && !isBefore(cursor, cost.endedFrom)) break;
const confirmation = confirmations.get(cursor) ?? null;
found.push({
recurringId: cost.id,
date: cursor,
amount: cost.amount,
confirmedBy: confirmation?.id ?? null,
});
// No rebasing: the grid never moves off a confirmation's date. (The
// `rebasedFrom` this used to call through returned `cursor` unchanged for
// `calendar` anyway, so dropping it changes nothing here.)
// No rebasing: the grid never moves off a confirmation's date. Only the
// anchored walk measures from when money moved, and it has no slots.
const next = stepFrom(cursor, cost.recurrence, cost.anchoring);
// The loop terminates only if the step strictly increases. `n >= 1` is
@@ -299,10 +301,33 @@ function gridOccurrences(
`A recurrence must move strictly forward; check that n is at least 1.`,
);
}
yield {
recurringId: cost.id,
date: cursor,
amount: cost.amount,
confirmedBy: confirmation?.id ?? null,
};
cursor = next;
}
}
return found;
/** First still-open slot in or after the requested month, without a preview cutoff. */
export function nextUnconfirmedOccurrenceOnOrAfter(
dataset: Dataset,
recurringId: RecurringCostId,
monthId: MonthId,
): Occurrence | null {
const cost = dataset.recurring.find(item => item.id === recurringId);
if (!cost) return null;
// Through `walkFor`, not the grid walk directly: an anchored cost's next due
// date is one cycle after its latest charge, and the grid would show the
// slot-model answer the anchored walk exists to replace.
for (const occurrence of walkFor(cost, dataset.purchases)) {
if (compareMonths(monthOf(occurrence.date), monthId) >= 0 && !occurrence.confirmedBy) {
return occurrence;
}
}
return null;
}
/**
+6
View File
@@ -43,3 +43,9 @@ test("the seed dataset renders a valid month view", () => {
test("newId returns distinct ids", () => {
expect(newId()).not.toBe(newId());
});
test("fresh dataset starts Unfiled", () => {
const data = createSeedDataset("2026-09");
expect(data.folders).toEqual([]);
expect(data.posts.every((post) => post.folderId === null)).toBe(true);
});
+3 -1
View File
@@ -1,7 +1,7 @@
import { DEFAULT_DIGITS, SEED_CURRENCIES } from "./types.ts";
import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts";
export const SCHEMA_VERSION = 8;
export const SCHEMA_VERSION = 11;
const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const;
@@ -24,6 +24,7 @@ export function createSeedDataset(
id: newId(),
name,
order,
folderId: null,
archived: false,
currency: "DKK",
// A new user has genuinely not budgeted anything yet. This is a different
@@ -42,6 +43,7 @@ export function createSeedDataset(
// out from under it the first time the user changes a rate or a currency.
currencies: currencies.map((currency) => ({ ...currency })),
fxRates: fxRates.map((rate) => ({ ...rate })),
folders: [],
posts,
months: [
{ id: startMonth, income: { amount: 0, currency: "DKK" }, ruleOverrides: {} },
+17 -1
View File
@@ -54,6 +54,14 @@ export type MonthId = string;
export type IsoDate = string;
export type PostId = string;
export type PurchaseId = string;
export type FolderId = string;
export interface BudgetFolder {
id: FolderId;
name: string;
parentId: FolderId | null;
order: number;
}
export type Rule =
| { kind: "fixed"; amount: Money }
@@ -69,6 +77,7 @@ export interface Post {
id: PostId;
name: string;
order: number;
folderId: FolderId | null;
archived: boolean;
/** Display currency for this post's own views only. All totals use base. */
currency: Currency;
@@ -141,6 +150,9 @@ export interface RecurringCost {
id: RecurringCostId;
name: string;
order: number;
folderId: FolderId | null;
/** When set, the bill is displayed beneath this post and follows its folder. */
parentPostId?: PostId;
/**
* A UI flag ONLY. Projection is governed by `startDate` and `endedFrom`,
* exactly as an archived post still folds — archiving must not retroactively
@@ -170,13 +182,15 @@ export interface RecurringCost {
endedFrom?: IsoDate;
recurrence: Recurrence;
anchoring: Anchoring;
/** Same shape and same invariant as Purchase: exactly one absorbsRemainder. */
/** Bills require exactly one remainder-absorbing destination. */
splitMode: "percent" | "fixed";
splits: Split[];
}
export interface Purchase {
id: PurchaseId;
/** Optional standalone budget row; splits remain the financial destination. */
placement?: { folderId: FolderId | null; order: number };
/**
* When it was bought. Month-only is normal — the app is about monthly
* spending — EXCEPT on a charge for a `lastCharge` recurring cost, where
@@ -191,6 +205,7 @@ export interface Purchase {
/** In the purchase's own currency. */
total: Money;
splitMode: "percent" | "fixed";
/** Empty means spent in the month without charging any post. */
splits: Split[];
/** null means the whole total is charged in the month of `date`. */
schedule: Schedule | null;
@@ -239,6 +254,7 @@ export interface Dataset {
*/
currencies: CurrencyDef[];
fxRates: FxRate[];
folders: BudgetFolder[];
posts: Post[];
months: Month[];
purchases: Purchase[];
+68
View File
@@ -17,6 +17,7 @@ function post(id: string, order: number, percent: number, archived = false): Pos
id,
name: id,
order,
folderId: null,
archived,
currency: "DKK",
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent } }],
@@ -39,6 +40,7 @@ const data: Dataset = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS },
currencies: TEST_CURRENCIES,
fxRates: [],
folders: [],
posts: [post("food", 0, 20), post("games", 1, 10)],
months: [
{ id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} },
@@ -53,6 +55,33 @@ const data: Dataset = {
};
describe("monthView", () => {
test("counts a loose purchase as spent without charging a post", () => {
const loose: Dataset = { ...data, purchases: [{ ...spend("loose", "food", 75, "2026-01"), splits: [] }] };
const view = monthView(loose, "2026-01");
expect(view.totalCharges).toBe(75);
expect(view.rows.every((row) => row.figures.charges === 0)).toBe(true);
expect(view.rows[0]!.figures.remaining).toBe(2000);
});
test("counts financed loose slices in their months and converts currency", () => {
const loose: Dataset = {
...data,
fxRates: [{ currency: "EUR", baseUnitsPerOne: 7.5, updatedAt: "2026-01-01", source: "manual" }],
purchases: [{
...spend("loose", "food", 10, "2026-01"),
total: { amount: 10, currency: "EUR" },
splits: [],
schedule: { slices: [
{ month: "2026-01", amount: { amount: 4, currency: "EUR" } },
{ month: "2026-02", amount: { amount: 6, currency: "EUR" } },
] },
}],
};
expect(monthView(loose, "2026-01").totalCharges).toBe(30);
expect(monthView(loose, "2026-02").totalCharges).toBe(45);
expect(yearView(loose, 2026).totalCharges).toBe(75);
expect(summaryView(loose, "2026-01", "2026-02").looseCharges).toBe(75);
});
test("reports income, allocations, and the unallocated remainder", () => {
const view = monthView(data, "2026-01");
expect(view.income).toBe(10000);
@@ -102,6 +131,35 @@ describe("monthView", () => {
expect(view.overspentCount).toBe(0);
});
test("no allocation rule stays distinct from an explicit zero allocation", () => {
const unbudgeted = { ...post("none", 0, 0), rules: [] };
const zero = post("zero", 1, 0);
const caseData: Dataset = {
...data,
posts: [unbudgeted, zero],
purchases: [spend("a", "none", 25, "2026-01"), spend("b", "zero", 25, "2026-01")],
};
const view = monthView(caseData, "2026-01");
expect(view.rows.map((row) => row.hasAllocationRule)).toEqual([false, true]);
expect(view.rows.map((row) => row.figures.remaining)).toEqual([-25, -25]);
expect(view.overspentCount).toBe(1);
expect(yearView(caseData, 2026).rows.map((row) => row.hasAllocationRuleByMonth[0])).toEqual([false, true]);
});
test("a monthly override counts as an allocation rule even before any standing rule", () => {
const caseData: Dataset = {
...data,
posts: [{ ...post("none", 0, 0), rules: [] }],
months: [{ id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {
none: { kind: "fixed", amount: { amount: 0, currency: "DKK" } },
} }],
purchases: [spend("a", "none", 25, "2026-01")],
};
const view = monthView(caseData, "2026-01");
expect(view.rows[0]!.hasAllocationRule).toBe(true);
expect(view.overspentCount).toBe(1);
});
test("archived posts appear only when they have activity", () => {
const withArchived: Dataset = {
...data,
@@ -137,6 +195,7 @@ describe("monthView", () => {
id: "r1",
name: "Storage unit",
order: 0,
folderId: null,
archived: false,
amount: { amount: 400, currency: "DKK" },
startDate: "2026-01",
@@ -181,6 +240,15 @@ describe("yearView", () => {
});
describe("summaryView", () => {
test("difference follows the dataset's decimal setting", () => {
const precise: Dataset = {
...data,
settings: { ...data.settings, digits: 3 },
months: [{ id: "2026-01", income: { amount: 1.234, currency: "DKK" }, ruleOverrides: {} }],
purchases: [spend("small", "food", 0.111, "2026-01-10")],
};
expect(summaryView(precise, "2026-01", "2026-01").difference).toBe(1.123);
});
test("totals charges by post and by month across a range", () => {
const view = summaryView(data, "2026-01", "2026-02");
expect(view.totalCharges).toBe(800);
+48 -18
View File
@@ -1,4 +1,5 @@
import { incomeFor, isOverridden } from "./allocation.ts";
import { incomeFor, isOverridden, ruleForPost } from "./allocation.ts";
import { looseChargesForMonth } from "./charges.ts";
import { figuresFor, foldBalances, type Fold, type PostMonthFigures } from "./fold.ts";
import { roundMoney } from "./money.ts";
import { compareMonths, monthOf, monthRange, monthsOfYear } from "./months.ts";
@@ -8,6 +9,8 @@ export interface MonthPostRow {
post: Post;
figures: PostMonthFigures;
overridden: boolean;
/** An applicable standing rule or monthly override, including an explicit zero. */
hasAllocationRule: boolean;
}
export interface MonthViewModel {
@@ -15,11 +18,13 @@ export interface MonthViewModel {
income: number;
totalAllocation: number;
totalCharges: number;
looseCharges: number;
/** income - totalAllocation. Negative when allocations exceed income. */
unallocated: number;
/**
* How many rows are overspent — `remaining < 0`, so a post at exactly zero
* does not count. Computed here rather than in the component because it is
* How many budgeted rows are overspent — `remaining < 0` with an applicable
* allocation rule. A post with no rule still shows its negative balance,
* but has no budget to overspend. Computed here because it is
* the month-level answer to "did I overspend", and it belongs where the
* rest of the month's arithmetic is tested.
*/
@@ -57,17 +62,22 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
const figures = figuresFor(fold, post.id, monthId);
// Archived posts stay visible only while they still have activity.
if (post.archived && !hasActivity(figures)) continue;
rows.push({ post, figures, overridden: isOverridden(month, post.id) });
rows.push({
post,
figures,
overridden: isOverridden(month, post.id),
hasAllocationRule: ruleForPost(month, post, monthId) !== null,
});
}
const base = dataset.settings.baseCurrency;
const baseDigits = dataset.settings.digits;
const looseCharges = looseChargesForMonth(dataset, monthId);
const totalAllocation = roundMoney(
rows.reduce((sum, r) => sum + r.figures.allocation, 0),
baseDigits,
);
const totalCharges = roundMoney(
rows.reduce((sum, r) => sum + r.figures.charges, 0),
rows.reduce((sum, r) => sum + r.figures.charges, looseCharges),
baseDigits,
);
@@ -76,8 +86,9 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
income,
totalAllocation,
totalCharges,
looseCharges,
unallocated: roundMoney(income - totalAllocation, baseDigits),
overspentCount: rows.filter((r) => r.figures.remaining < 0).length,
overspentCount: rows.filter((r) => r.hasAllocationRule && r.figures.remaining < 0).length,
rows,
};
}
@@ -86,6 +97,7 @@ export interface YearPostRow {
post: Post;
/** Twelve entries, January to December. */
byMonth: PostMonthFigures[];
hasAllocationRuleByMonth: boolean[];
totalAllocation: number;
totalCharges: number;
/** December's remaining balance. */
@@ -98,22 +110,27 @@ export interface YearViewModel {
incomeByMonth: number[];
totalIncome: number;
totalCharges: number;
looseByMonth: number[];
totalLooseCharges: number;
rows: YearPostRow[];
}
export function yearView(dataset: Dataset, year: number): YearViewModel {
const months = monthsOfYear(year);
const fold = foldBalances(dataset, months[11]!);
const base = dataset.settings.baseCurrency;
const baseDigits = dataset.settings.digits;
const incomeByMonth = months.map((m) => incomeFor(dataset, m));
const looseByMonth = months.map((m) => looseChargesForMonth(dataset, m));
const rows: YearPostRow[] = visiblePosts(dataset).map((post) => {
const byMonth = months.map((m) => figuresFor(fold, post.id, m));
return {
post,
byMonth,
hasAllocationRuleByMonth: months.map((monthId) =>
ruleForPost(dataset.months.find((m) => m.id === monthId), post, monthId) !== null,
),
totalAllocation: roundMoney(
byMonth.reduce((sum, f) => sum + f.allocation, 0),
baseDigits,
@@ -135,9 +152,11 @@ export function yearView(dataset: Dataset, year: number): YearViewModel {
baseDigits,
),
totalCharges: roundMoney(
rows.reduce((sum, r) => sum + r.totalCharges, 0),
rows.reduce((sum, r) => sum + r.totalCharges, looseByMonth.reduce((a, b) => a + b, 0)),
baseDigits,
),
looseByMonth,
totalLooseCharges: roundMoney(looseByMonth.reduce((a, b) => a + b, 0), baseDigits),
rows,
};
}
@@ -148,7 +167,9 @@ export interface SummaryViewModel {
byPost: { post: Post; charges: number }[];
byMonth: { monthId: MonthId; charges: number }[];
totalCharges: number;
looseCharges: number;
totalIncome: number;
difference: number;
}
export function summaryView(
@@ -175,25 +196,34 @@ export function summaryView(
charges: roundMoney(
dataset.posts.reduce(
(sum, p) => sum + figuresFor(fold, p.id, monthId).charges,
0,
looseChargesForMonth(dataset, monthId),
),
baseDigits,
),
}));
const totalCharges = roundMoney(
byMonth.reduce((sum, m) => sum + m.charges, 0),
baseDigits,
);
const looseCharges = roundMoney(
months.reduce((sum, monthId) => sum + looseChargesForMonth(dataset, monthId), 0),
baseDigits,
);
const totalIncome = roundMoney(
months.reduce((sum, m) => sum + incomeFor(dataset, m), 0),
baseDigits,
);
return {
from,
to,
byPost,
byMonth,
totalCharges: roundMoney(
byMonth.reduce((sum, m) => sum + m.charges, 0),
baseDigits,
),
totalIncome: roundMoney(
months.reduce((sum, m) => sum + incomeFor(dataset, m), 0),
baseDigits,
),
totalCharges,
looseCharges,
totalIncome,
difference: roundMoney(totalIncome - totalCharges, baseDigits),
};
}
+120 -11
View File
@@ -6,6 +6,7 @@ import {
ImportValidationError,
exportFilename,
} from "./json.ts";
import { addRecurringCost, updatePost, updateRecurringCost } from "../store/actions.ts";
import { createSeedDataset } from "../domain/seed.ts";
import { monthView, yearView } from "../domain/views.ts";
import { occurrencesByMonth } from "../domain/occurrences.ts";
@@ -53,6 +54,11 @@ describe("round trip", () => {
});
describe("validation", () => {
test("accepts a purchase with no post and preserves it through export", () => {
const data = populated();
data.purchases[0]!.splits = [];
expect(parseDatasetJson(exportDatasetJson(data)).purchases[0]!.splits).toEqual([]);
});
test("rejects malformed JSON", () => {
expect(() => parseDatasetJson("{not json")).toThrow(ImportValidationError);
});
@@ -89,12 +95,6 @@ describe("validation", () => {
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/exactly one/i);
});
test("rejects a purchase with no splits", () => {
const data = populated();
data.purchases[0]!.splits = [];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/at least one split/i);
});
test("rejects an unsupported currency", () => {
const data = populated() as any;
// CHF, not GBP: GBP is one of the currencies the app ships with now.
@@ -115,6 +115,101 @@ describe("validation", () => {
});
});
describe("import rejects invalid folder graph", () => {
const folder = (id: string, parentId: string | null, order: number) => ({ id, name: id, parentId, order });
const parse = (data: Dataset) => parseDatasetJson(JSON.stringify(data));
test("round-trips a standalone purchase and a bill nested beneath a post", () => {
const data = populated();
data.folders = [folder("home", null, 3)];
data.purchases[0]!.placement = { folderId: "home", order: 0 };
data.recurring.push({ id: "rent", name: "Rent", order: 0, folderId: null, parentPostId: data.posts[0]!.id, archived: false, amount: { amount: 100, currency: "DKK" }, startDate: "2026-01", recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }] });
const restored = parse(data);
expect(restored.purchases[0]!.placement?.folderId).toBe("home");
expect(restored.recurring[0]!.parentPostId).toBe(data.posts[0]!.id);
restored.recurring[0]!.parentPostId = "missing";
expect(() => parse(restored)).toThrow(/parent post/);
restored.recurring[0]!.parentPostId = data.posts[0]!.id;
restored.purchases[0]!.placement!.folderId = "missing";
expect(() => parse(restored)).toThrow(/unknown folder/);
});
test("valid nested tree parses", () => {
const data = populated();
data.folders = [folder("home", null, 0), folder("bills", "home", 0)];
data.posts[0]!.folderId = "bills";
expect(parse(data).posts[0]!.folderId).toBe("bills");
});
test("rejects duplicate folder IDs", () => {
const data = populated();
data.folders = [folder("same", null, 0), folder("same", null, 1)];
expect(() => parse(data)).toThrow(ImportValidationError);
});
test("rejects missing parent", () => {
const data = populated();
data.folders = [folder("child", "missing", 0)];
expect(() => parse(data)).toThrow(ImportValidationError);
});
test("rejects missing item folders", () => {
const data = populated();
data.posts[0]!.folderId = "missing";
expect(() => parse(data)).toThrow(ImportValidationError);
data.posts[0]!.folderId = null;
data.recurring.push({
id: "rent", name: "Rent", order: 3, folderId: "missing", archived: false,
amount: { amount: 100, currency: "DKK" }, startDate: "2026-01",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
});
expect(() => parse(data)).toThrow(ImportValidationError);
});
test("rejects cycles", () => {
const data = populated();
data.folders = [folder("a", "b", 0), folder("b", "a", 0)];
expect(() => parse(data)).toThrow(ImportValidationError);
});
test("rejects duplicate sibling order across item kinds", () => {
const data = populated();
data.folders = [folder("bills", null, 0)];
data.posts[0]!.order = 0;
expect(() => parse(data)).toThrow(ImportValidationError);
});
test.each([
["", "Rent"], [" ", "Rent"], ["Games", ""], ["Games", " "],
])("blank item names (%j, %j) survive saved dataset round trips and v7 import", (postName, billName) => {
const data = populated();
const bill = addRecurringCost(data, {
name: "Rent", folderId: null, archived: false,
amount: { amount: 100, currency: "DKK" }, startDate: "2026-01",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
});
updatePost(data, data.posts[0]!.id, { name: postName });
updateRecurringCost(data, bill.id, { name: billName });
expect(parseDatasetJson(exportDatasetJson(data))).toEqual(data);
const legacy = JSON.parse(exportDatasetJson(data));
legacy.settings.schemaVersion = 7;
delete legacy.folders;
for (const item of [...legacy.posts, ...legacy.recurring]) delete item.folderId;
const imported = parseDatasetJson(JSON.stringify(legacy));
expect(imported.posts[0]!.name).toBe(postName);
expect(imported.recurring[0]!.name).toBe(billName);
expect(imported.folders).toEqual([]);
});
test.each(["", " "])("still rejects blank folder names %j", name => {
const data = populated();
data.folders = [{ ...folder("home", null, data.posts.length), name }];
expect(() => parse(data)).toThrow(/empty name/);
});
});
test("describeDataset counts what an import would replace", () => {
expect(describeDataset(populated())).toEqual({
posts: 3,
@@ -231,6 +326,19 @@ describe("rule versions on import", () => {
data.posts[0].rules = [{ from: "2026-01", rule: { kind: "everySecondTuesday" } }];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/everySecondTuesday/);
});
test.each([
["missing percent", { kind: "percentOfIncome" }],
["string percent", { kind: "percentOfIncome", percent: "10" }],
["null percent", { kind: "percentOfIncome", percent: null }],
["missing fixed amount", { kind: "fixed", amount: { currency: "DKK" } }],
["string fixed amount", { kind: "fixed", amount: { amount: "500", currency: "DKK" } }],
["null fixed amount", { kind: "fixed", amount: { amount: null, currency: "DKK" } }],
])("rejects a %s before it can corrupt the fold", (_label, rule) => {
const data = populated() as any;
data.posts[0].rules = [{ from: "2026-01", rule }];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(ImportValidationError);
});
});
describe("the decimal-places setting on import", () => {
@@ -274,7 +382,8 @@ describe("recurring cost validation", () => {
const valid = {
id: "r1",
name: "Rent",
order: 0,
order: 3,
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
@@ -518,7 +627,7 @@ describe("Purchase.source validation", () => {
test("a source with a malformed occurrence date is refused", () => {
const data: any = JSON.parse(withSource({ recurringId: "r1", occurrenceDate: "nope" }));
data.recurring = [{
id: "r1", name: "Rent", order: 0, archived: false,
id: "r1", name: "Rent", order: 3, folderId: null, archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
recurrence: { kind: "everyNMonths", n: 1 },
@@ -570,7 +679,7 @@ describe("the occurrenceDate / anchoring pairing", () => {
const data: any = createSeedDataset("2026-09");
data.posts[0].id = "housing";
data.recurring = [{
id: "r1", name: "Bill", order: 0, archived: false,
id: "r1", name: "Bill", order: 99, folderId: null, archived: false,
amount: { amount: 300, currency: "DKK" },
startDate: anchoring === "lastCharge" ? "2026-09-01" : "2026-09",
recurrence: { kind: "everyNMonths", n: 1 },
@@ -638,7 +747,7 @@ describe("startDate granularity under the anchored model", () => {
const data: any = createSeedDataset("2026-09");
data.posts[0].id = "housing";
data.recurring = [{
id: "r1", name: "Bill", order: 0, archived: false,
id: "r1", name: "Bill", order: 99, folderId: null, archived: false,
amount: { amount: 300, currency: "DKK" },
startDate, recurrence: kind === "everyNMonths" ? { kind, n: 1 } : { kind, n: 30 },
anchoring, splitMode: "percent",
@@ -679,7 +788,7 @@ describe("an anchored cost's CHARGE dates need a day at the import boundary", ()
const data: any = createSeedDataset("2026-09");
data.posts[0].id = "housing";
data.recurring = [{
id: "r1", name: "Bill", order: 0, archived: false,
id: "r1", name: "Bill", order: 99, folderId: null, archived: false,
amount: { amount: 300, currency: "DKK" },
// Before the charge, so the charge's own date is the anchor the walk
// uses rather than being masked by `max(latest, startDate)`.
+112 -14
View File
@@ -1,4 +1,4 @@
import type { Currency, CurrencyDef, Dataset, MonthId } from "../domain/types.ts";
import type { Currency, CurrencyDef, Dataset, FolderId, MonthId } from "../domain/types.ts";
import { migrate } from "../store/migrations.ts";
import { monthOf } from "../domain/months.ts";
import { toDayOrdinal } from "../domain/days.ts";
@@ -136,6 +136,92 @@ function requireArray(data: Record<string, unknown>, key: string): unknown[] {
return value;
}
function requireFolderTree(data: Dataset): void {
const folders = requireArray(data as unknown as Record<string, unknown>, "folders");
const parents = new Map<FolderId, FolderId | null>();
const siblingOrders = new Map<FolderId | null, Set<number>>();
const nestedBillOrders = new Map<string, Set<number>>();
const requireOrder = (order: unknown, label: string) => {
if (typeof order !== "number" || !Number.isFinite(order) || !Number.isInteger(order)) {
throw new ImportValidationError(`${label} has an invalid order`);
}
};
const requireLocation = (folderId: unknown, order: unknown, label: string) => {
if (folderId !== null && (typeof folderId !== "string" || !parents.has(folderId))) {
throw new ImportValidationError(`${label} references unknown folder "${String(folderId)}"`);
}
requireOrder(order, label);
const key = folderId as FolderId | null;
const orders = siblingOrders.get(key) ?? new Set<number>();
if (orders.has(order as number)) {
throw new ImportValidationError(`${label} duplicates sibling order ${String(order)}`);
}
orders.add(order as number);
siblingOrders.set(key, orders);
};
for (const entry of folders) {
const folder = entry as Record<string, unknown> | null;
if (!folder || typeof folder.id !== "string" || !folder.id.trim()) {
throw new ImportValidationError("Folder has an invalid id");
}
if (parents.has(folder.id)) {
throw new ImportValidationError(`Duplicate folder id "${folder.id}"`);
}
if (typeof folder.name !== "string" || !folder.name.trim()) {
throw new ImportValidationError(`Folder "${folder.id}" has an empty name`);
}
if (folder.parentId !== null && typeof folder.parentId !== "string") {
throw new ImportValidationError(`Folder "${folder.id}" has an invalid parent`);
}
parents.set(folder.id, folder.parentId as FolderId | null);
}
for (const [id, parentId] of parents) {
if (parentId !== null && !parents.has(parentId)) {
throw new ImportValidationError(`Folder "${id}" references unknown parent "${parentId}"`);
}
const visited = new Set<FolderId>();
let cursor: FolderId | null = id;
while (cursor !== null) {
if (visited.has(cursor)) throw new ImportValidationError(`Folder cycle at "${cursor}"`);
visited.add(cursor);
cursor = parents.get(cursor) ?? null;
}
}
for (const entry of folders) {
const folder = entry as Record<string, unknown>;
requireLocation(folder.parentId, folder.order, `Folder "${folder.id}"`);
}
for (const post of data.posts) {
if (typeof post?.name !== "string") {
throw new ImportValidationError("Post name must be a string");
}
requireLocation(post?.folderId, post?.order, `Post "${post?.name}"`);
}
for (const cost of data.recurring) {
if (typeof cost?.name !== "string") {
throw new ImportValidationError("Recurring cost name must be a string");
}
if (cost.parentPostId !== undefined) {
if (!data.posts.some(post => post.id === cost.parentPostId)) throw new ImportValidationError(`Recurring cost "${cost.name}" references unknown parent post`);
if (cost.folderId !== null && !parents.has(cost.folderId)) throw new ImportValidationError(`Recurring cost "${cost.name}" references unknown folder`);
requireOrder(cost.order, `Recurring cost "${cost.name}"`);
const orders = nestedBillOrders.get(cost.parentPostId) ?? new Set<number>();
if (orders.has(cost.order)) throw new ImportValidationError(`Recurring cost "${cost.name}" duplicates nested order ${cost.order}`);
orders.add(cost.order);
nestedBillOrders.set(cost.parentPostId, orders);
} else requireLocation(cost?.folderId, cost?.order, `Recurring cost "${cost?.name}"`);
}
for (const purchase of data.purchases) {
if (purchase.placement !== undefined) {
if (!purchase.placement || typeof purchase.placement !== "object") throw new ImportValidationError(`Purchase "${purchase.description}" has invalid placement`);
requireLocation(purchase.placement.folderId, purchase.placement.order, `Purchase "${purchase.description}"`);
}
}
}
/**
* `n` is checked hard rather than clamped. The projection walk in
* `domain/occurrences.ts` terminates only if every step strictly advances, and
@@ -175,8 +261,8 @@ function requireRecurrence(recurrence: any, label: string): void {
* `distributeByWeight`/`distributeByAmount` branch in `domain/charges.ts`.
* One helper means the two write paths cannot silently drift into checking
* to different standards; the two loops used to each carry their own copy of
* the "no splits" / "one absorber" / "unknown post" checks, so this replaces
* both rather than adding a third copy.
* the "one absorber" / "unknown post" checks, so this replaces both rather
* than adding a third copy. Bills require a split; purchases may be unassigned.
*
* `splitMode` must be one of the two known modes: `charges.ts` picks its
* distribution function with `splitMode === "percent" ? ... : ...`, so any
@@ -199,15 +285,17 @@ function requireSplits(
splitMode: unknown,
label: string,
postIds: ReadonlySet<string>,
allowEmpty = false,
): void {
if (!SPLIT_MODES.has(splitMode as string)) {
throw new ImportValidationError(
`${label} has an unknown splitMode "${String(splitMode)}"`,
);
}
if (!Array.isArray(splits) || splits.length === 0) {
if (!Array.isArray(splits) || (splits.length === 0 && !allowEmpty)) {
throw new ImportValidationError(`${label} has no splits; at least one split is required`);
}
if (splits.length === 0) return;
const absorbers = (splits as Array<Record<string, unknown>>).filter(
(s) => s?.absorbsRemainder,
).length;
@@ -230,7 +318,7 @@ function requireSplits(
/**
* Validates far enough that the fold cannot throw on the imported data: every
* split points at a real post, every purchase has exactly one remainder
* split points at a real post, every assigned purchase has exactly one remainder
* absorber, every MonthId parses, every rule version resolves unambiguously
* to a number rather than to NaN or a missing rate, and every date the
* projection walk will STEP FROM is both granular enough for the schedule
@@ -243,7 +331,7 @@ function requireSplits(
*
* Note the order this runs in: `migrate` first, so what is validated is the
* upgraded dataset. A file the migration produces must therefore satisfy
* every rule below — see the 7 -> 8 step, which expands exactly the
* every rule below — see the 10 -> 11 step, which expands exactly the
* month-only charge dates this function refuses.
*/
export function parseDatasetJson(text: string): Dataset {
@@ -313,18 +401,27 @@ export function parseDatasetJson(text: string): Dataset {
}
seenFrom.add(from);
// The rule's VALUE is deliberately unvalidated — a percentage above 100
// and a negative amount are both legal. Its SHAPE is not: an unknown
// kind falls through resolveRule's percentage branch and turns every
// figure after it into NaN, and an unsupported currency throws
// MissingRateError deep inside the fold rather than here.
// Rule values may be negative or over 100, but must be finite numbers.
// A missing or non-numeric value can silently turn the fold into NaN.
const rule = (version.rule ?? {}) as {
kind?: unknown;
amount?: { currency?: unknown };
amount?: { amount?: unknown; currency?: unknown };
percent?: unknown;
};
if (rule.kind === "fixed") {
requireCurrency(rule.amount?.currency, `post "${post.name}" rule from ${from}`, defined);
} else if (rule.kind !== "percentOfIncome") {
if (typeof rule.amount?.amount !== "number" || !Number.isFinite(rule.amount.amount)) {
throw new ImportValidationError(
`Post "${post.name}" has a fixed rule from "${from}" with an amount that is not a finite number`,
);
}
} else if (rule.kind === "percentOfIncome") {
if (typeof rule.percent !== "number" || !Number.isFinite(rule.percent)) {
throw new ImportValidationError(
`Post "${post.name}" has a percentage rule from "${from}" that is not a finite number`,
);
}
} else {
throw new ImportValidationError(
`Post "${post.name}" has a rule from "${from}" of unknown kind "${String(rule.kind)}"`,
);
@@ -334,6 +431,7 @@ export function parseDatasetJson(text: string): Dataset {
const recurring = requireArray(data, "recurring") as any[];
dataset.recurring = recurring;
requireFolderTree(dataset);
for (const cost of recurring) {
if (!cost || typeof cost !== "object") {
@@ -420,7 +518,7 @@ export function parseDatasetJson(text: string): Dataset {
if (!PURCHASE_DATE.test(purchase.date)) {
throw new ImportValidationError(`${label} has an invalid date "${purchase.date}"`);
}
requireSplits(purchase.splits, purchase.splitMode, label, postIds);
requireSplits(purchase.splits, purchase.splitMode, label, postIds, true);
for (const slice of purchase.schedule?.slices ?? []) {
if (!MONTH_ID.test(slice.month)) {
throw new ImportValidationError(`${label} has invalid slice month "${slice.month}"`);
+18
View File
@@ -25,6 +25,24 @@ function populated(): Dataset {
}
describe("buildWorkbook", () => {
test("exports an unassigned purchase as a row", () => {
const data = populated();
data.purchases[0]!.splits = [];
const purchases = buildWorkbook(data).find((sheet) => sheet.name === "Purchases")!;
expect(purchases.rows).toHaveLength(2);
expect(purchases.rows[1]![3]).toEqual({ kind: "text", value: "Unassigned" });
});
test("exports each unassigned purchase amount separately", () => {
const data = populated();
data.purchases[0]!.splits = [];
data.purchases.push({ ...data.purchases[0]!, id: "p2", total: { amount: 50, currency: "DKK" } });
const purchases = buildWorkbook(data).find((sheet) => sheet.name === "Purchases")!;
expect(purchases.rows.slice(1).map((row) => row[4])).toEqual([
{ kind: "number", value: 400 },
{ kind: "number", value: 50 },
]);
});
test("includes Summary, Months, Purchases, and one sheet per post", () => {
const names = buildWorkbook(populated()).map((s) => s.name);
expect(names.slice(0, 3)).toEqual(["Summary", "Months", "Purchases"]);
+18 -1
View File
@@ -1,5 +1,6 @@
import { zipSync, strToU8 } from "fflate";
import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../domain/charges.ts";
import { chargesForPurchaseInMonth, looseChargesForMonth, sliceAmountForMonth } from "../domain/charges.ts";
import { toBase } from "../domain/fx.ts";
import { monthRange } from "../domain/months.ts";
import { roundMoney } from "../domain/money.ts";
import { datasetMonthSpan, monthView, summaryView } from "../domain/views.ts";
@@ -35,6 +36,15 @@ function summarySheet(dataset: Dataset, months: MonthId[], byMonth: FiguresByMon
]);
}
const loose = months.map((m) => looseChargesForMonth(dataset, m));
if (loose.some((amount) => amount !== 0)) {
rows.push([
text("Unassigned"),
...loose.map(num),
num(roundMoney(loose.reduce((a, b) => a + b, 0), dataset.settings.digits)),
]);
}
const summary = summaryView(dataset, months[0]!, months[months.length - 1]!);
rows.push([
text("Total"),
@@ -89,6 +99,13 @@ function purchasesSheet(dataset: Dataset, months: MonthId[]): Sheet {
dataset.fxRates,
dataset.settings.digits,
);
if (purchase.splits.length === 0) {
const amount = toBase(slice, base, dataset.fxRates, dataset.settings.digits);
rows.push([
text(monthId), text(purchase.date), text(purchase.description), text("Unassigned"),
num(amount), num(slice.amount), text(slice.currency), text(purchase.schedule ? "yes" : "no"),
]);
}
for (const charge of charges) {
rows.push([
text(monthId),
+253 -10
View File
@@ -2,8 +2,159 @@ import { test, expect, describe } from "bun:test";
import * as actions from "./actions.ts";
import { createSeedDataset } from "../domain/seed.ts";
import { occurrencesByMonth } from "../domain/occurrences.ts";
import { monthView } from "../domain/views.ts";
import { createSnapshotStore } from "./snapshot.ts";
import { exportDatasetJson, parseDatasetJson } from "../export/json.ts";
import type { Dataset } from "../domain/types.ts";
const billInput = (postId: string, folderId: string | null = null) => ({
name: "Phone", folderId, archived: false,
amount: { amount: 30, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths" as const, n: 1 }, anchoring: "calendar" as const,
splitMode: "percent" as const,
splits: [{ postId, value: 100, absorbsRemainder: true }],
});
describe("budget folder placement", () => {
test("an unassigned purchase can create and charge its own post", () => {
const data = createSeedDataset("2026-09");
const folder = actions.addBudgetFolder(data, "Trips", null);
data.fxRates.push({ currency: "EUR", baseUnitsPerOne: 7.5, updatedAt: "2026-09-01", source: "manual" });
const purchase = actions.addPurchase(data, { date: "2026-09", description: "Train ticket", total: { amount: 50, currency: "EUR" }, splitMode: "percent", splits: [], schedule: null });
actions.placePurchase(data, purchase.id, folder.id);
const post = actions.createPostForPurchase(data, purchase.id);
expect(post).toMatchObject({ name: "Train ticket", currency: "EUR", folderId: folder.id });
expect(purchase.splits).toEqual([{ postId: post.id, value: 100, absorbsRemainder: true }]);
expect(monthView(data, "2026-09").looseCharges).toBe(0);
expect(() => actions.createPostForPurchase(data, purchase.id)).toThrow(/already assigned/i);
});
test("editing a purchase can place it in a folder without changing its charge", () => {
const data = createSeedDataset("2026-09");
const folder = actions.addBudgetFolder(data, "Trips", null);
const postId = data.posts[0]!.id;
const purchase = actions.addPurchase(data, { date: "2026-09", description: "Ticket", total: { amount: 50, currency: "DKK" }, splitMode: "percent", splits: [{ postId, value: 100, absorbsRemainder: true }], schedule: null });
actions.placePurchase(data, purchase.id, folder.id);
expect(purchase.placement?.folderId).toBe(folder.id);
expect(purchase.splits[0]!.postId).toBe(postId);
actions.placePurchase(data, purchase.id, null);
expect(purchase.placement?.folderId).toBe(null);
actions.placePurchase(data, purchase.id, undefined);
expect(purchase.placement).toBeUndefined();
});
test("a bill can be nested under a post and later moved out", () => {
const data = createSeedDataset("2026-09");
const bill = actions.addRecurringCost(data, billInput(data.posts[0]!.id));
actions.placeRecurringUnderPost(data, bill.id, data.posts[0]!.id);
expect(bill.parentPostId).toBe(data.posts[0]!.id);
actions.placeRecurringUnderPost(data, bill.id, data.posts[1]!.id);
expect(bill.parentPostId).toBe(data.posts[1]!.id);
actions.placeBudgetItem(data, { kind: "recurring", id: bill.id }, null, 3);
expect(bill.parentPostId).toBeUndefined();
});
test("removing a folder moves its direct contents to its parent in order", () => {
const data = createSeedDataset("2026-09");
const parent = actions.addBudgetFolder(data, "Parent", null);
const before = actions.addPost(data, "Before", "DKK", parent.id);
const removed = actions.addBudgetFolder(data, "Removed", parent.id);
const after = actions.addPost(data, "After", "DKK", parent.id);
const child = actions.addBudgetFolder(data, "Child", removed.id);
const post = actions.addPost(data, "Kept", "DKK", removed.id);
const bill = actions.addRecurringCost(data, billInput(post.id, removed.id));
const purchase = actions.addPurchase(data, { date: "2026-09", description: "Ticket", total: { amount: 20, currency: "DKK" }, splitMode: "percent", splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], schedule: null });
actions.placePurchase(data, purchase.id, removed.id);
actions.removeBudgetFolder(data, removed.id);
expect(data.folders.some(folder => folder.id === removed.id)).toBe(false);
expect([child.parentId, post.folderId, bill.folderId]).toEqual([parent.id, parent.id, parent.id]);
expect(actions.budgetSiblings(data, parent.id).map(item => item.id)).toEqual([before.id, child.id, post.id, bill.id, purchase.id, after.id]);
expect(bill.splits[0]!.postId).toBe(post.id);
expect(() => parseDatasetJson(exportDatasetJson(data))).not.toThrow();
});
test("mixed siblings reorder densely", () => {
const data = createSeedDataset("2026-09");
const folder = actions.addBudgetFolder(data, "Utilities", null);
actions.addBudgetFolder(data, "Subscriptions", folder.id);
const post = actions.addPost(data, "Bills", "DKK", folder.id);
const bill = actions.addRecurringCost(data, billInput(post.id, folder.id));
actions.placeBudgetItem(data, { kind: "recurring", id: bill.id }, folder.id, 0);
const siblings = actions.budgetSiblings(data, folder.id);
expect(siblings.map(item => item.order)).toEqual([0, 1, 2]);
expect(siblings[0]!.id).toBe(bill.id);
actions.placeBudgetItem(data, { kind: "folder", id: folder.id }, null, 0);
expect(actions.budgetSiblings(data, null)[0]!.id).toBe(folder.id);
});
test("invalid placement is atomic", () => {
const data = createSeedDataset("2026-09");
const parent = actions.addBudgetFolder(data, "Parent", null);
const child = actions.addBudgetFolder(data, "Child", parent.id);
const post = actions.addPost(data, "Bills", "DKK", parent.id);
const bill = actions.addRecurringCost(data, billInput(post.id));
const invalid = [
() => actions.placeBudgetItem(data, { kind: "post", id: post.id }, "ghost", 0),
() => actions.placeBudgetItem(data, { kind: "folder", id: parent.id }, parent.id, 0),
() => actions.placeBudgetItem(data, { kind: "folder", id: parent.id }, child.id, 0),
() => actions.placeBudgetItem(data, { kind: "post", id: "ghost" }, null, 0),
() => actions.placeBudgetItem(data, { kind: "recurring", id: bill.id }, null, 99),
() => actions.addBudgetFolder(data, " ", null),
() => actions.addBudgetFolder(data, "Unknown parent", "ghost"),
() => actions.renameBudgetFolder(data, parent.id, " "),
() => actions.addPost(data, "Orphan", "DKK", "ghost"),
() => actions.addRecurringCost(data, billInput(post.id, "ghost")),
() => actions.updatePost(data, post.id, { folderId: null } as never),
() => actions.updateRecurringCost(data, bill.id, { order: 0 } as never),
];
for (const operation of invalid) {
const before = JSON.stringify(data);
expect(operation).toThrow();
expect(JSON.stringify(data)).toBe(before);
}
actions.renameBudgetFolder(data, parent.id, "New name");
expect(parent.name).toBe("New name");
actions.placeBudgetItem(data, { kind: "folder", id: child.id }, null, 0);
expect(child.parentId).toBe(null);
expect(actions.budgetSiblings(data, parent.id).map(item => item.order)).toEqual([0]);
});
test("legacy move buttons use mixed order", () => {
const data = createSeedDataset("2026-09");
const bill = actions.addRecurringCost(data, billInput(data.posts[0]!.id));
actions.moveRecurringCost(data, bill.id, -1);
expect(actions.budgetSiblings(data, null).map(item => item.id)).toEqual([
data.posts[0]!.id, data.posts[1]!.id, bill.id, data.posts[2]!.id,
]);
actions.movePost(data, data.posts[2]!.id, -1);
expect(actions.budgetSiblings(data, null).map(item => item.id)).toEqual([
data.posts[0]!.id, data.posts[1]!.id, data.posts[2]!.id, bill.id,
]);
const before = JSON.stringify(data);
actions.movePost(data, data.posts[0]!.id, -1);
expect(JSON.stringify(data)).toBe(before);
});
test("new items append after sparse imported sibling orders", () => {
const data = createSeedDataset("2026-09");
data.posts[2]!.order = 3;
const folder = actions.addBudgetFolder(data, "Utilities", null);
const post = actions.addPost(data, "Bills", "DKK");
const bill = actions.addRecurringCost(data, billInput(post.id));
expect([folder.order, post.order, bill.order]).toEqual([4, 5, 6]);
});
test("adding after the largest finite sibling order stays importable", () => {
for (const add of [
(data: Dataset) => actions.addBudgetFolder(data, "Utilities", null),
(data: Dataset) => actions.addPost(data, "Bills", "DKK"),
(data: Dataset) => actions.addRecurringCost(data, billInput(data.posts[0]!.id)),
]) {
const data = createSeedDataset("2026-09");
data.posts[2]!.order = Number.MAX_VALUE;
const added = add(data);
expect(added.order).not.toBe(Number.MAX_VALUE);
expect(() => parseDatasetJson(exportDatasetJson(data))).not.toThrow();
}
});
});
/**
* Decimal places are one dataset-wide setting, so a different minor unit is
* expressed by moving `settings.digits` — no monkey-patching a module
@@ -198,6 +349,16 @@ describe("purchases", () => {
expect(data.purchases[0]!.description).toBe("Dinner");
});
test("an edited purchase can lose its post and bill association", () => {
const data = draft();
const bill = actions.addRecurringCost(data, billInput(data.posts[0]!.id));
const purchase = actions.confirmOccurrence(data, bill.id, "2026-09");
actions.updatePurchase(data, purchase.id, { splits: [], source: undefined });
expect(data.purchases[0]!.splits).toEqual([]);
expect(data.purchases[0]!.source).toBeUndefined();
expect(monthView(data, "2026-09").looseCharges).toBe(30);
});
test("updatePurchase rounds the total amount, split values, and schedule slice amounts", () => {
const data = draft();
const postId = data.posts[0]!.id;
@@ -464,6 +625,7 @@ describe("recurring costs", () => {
const rentInput = {
name: "Rent",
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
@@ -473,14 +635,92 @@ describe("recurring costs", () => {
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
};
test("dedicated post and bill save together", () => {
const data = draft();
const folder = actions.addBudgetFolder(data, "Housing", null);
const bill = actions.addRecurringCostWithPost(data, {
name: "Rent", folderId: folder.id, archived: false,
amount: { amount: 8000, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
}, "Rent post");
const post = data.posts.find(p => p.id === bill.splits[0]!.postId)!;
expect(post.name).toBe("Rent post");
expect(post.folderId).toBe(folder.id);
expect(bill.folderId).toBeNull();
expect(bill.parentPostId).toBe(post.id);
expect(bill.splits).toEqual([{ postId: post.id, value: 100, absorbsRemainder: true }]);
expect(post.rules).toEqual([{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 8000, currency: "DKK" } } }]);
actions.confirmOccurrence(data, bill.id, "2026-09");
const figures = monthView(data, "2026-09").rows.find(row => row.post.id === post.id)!.figures;
expect(figures.allocation).toBe(8000);
expect(figures.charges).toBe(8000);
expect(figures.remaining).toBe(0);
});
test("dedicated post allocates a rounded monthly share of a less frequent bill", () => {
const data = draft();
const bill = actions.addRecurringCostWithPost(data, {
name: "Insurance", folderId: null, archived: false,
amount: { amount: 1200, currency: "DKK" }, startDate: "2026-10",
recurrence: { kind: "everyNMonths", n: 12 }, anchoring: "calendar",
}, "Insurance");
const post = data.posts.find(p => p.id === bill.splits[0]!.postId)!;
expect(post.rules[0]).toEqual({ from: "2026-10", rule: { kind: "fixed", amount: { amount: 100, currency: "DKK" } } });
});
test("a rejected store mutation leaves neither dedicated record", async () => {
const data = draft();
let writes = 0;
const snapshot = createSnapshotStore({ read: async () => data, write: async () => { writes++; throw new Error("Disk full"); } }, "2026-01");
await snapshot.load();
await expect(snapshot.mutate(draft => {
actions.addRecurringCostWithPost(draft, {
name: "Rent", folderId: null, archived: false,
amount: { amount: 8000, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
}, "Rent post");
})).rejects.toThrow("Disk full");
expect(writes).toBe(1);
expect(snapshot.get().posts.some(post => post.name === "Rent post")).toBe(false);
expect(snapshot.get().recurring).toHaveLength(0);
});
test("editing split mode, values and absorber moves projected figures", () => {
const data = draft();
const other = data.posts[1]!;
const bill = actions.addRecurringCost(data, { ...rentInput, amount: { amount: 30, currency: "DKK" }, startDate: "2026-09" });
expect(monthView(data, "2026-09").rows.find(row => row.post.id === "housing")!.figures.expected).toBe(30);
actions.updateRecurringCost(data, bill.id, {
splitMode: "fixed",
splits: [
{ postId: "housing", value: 10, absorbsRemainder: false },
{ postId: other.id, value: 0, absorbsRemainder: true },
],
});
expect(bill.splitMode).toBe("fixed");
expect(bill.splits.map(split => split.absorbsRemainder)).toEqual([false, true]);
expect(monthView(data, "2026-09").rows.find(row => row.post.id === "housing")!.figures.expected).toBe(10);
expect(monthView(data, "2026-09").rows.find(row => row.post.id === other.id)!.figures.expected).toBe(20);
});
test("recurring split destinations must be active posts", () => {
const data = draft();
data.posts[1]!.archived = true;
expect(() => actions.addRecurringCost(data, { ...rentInput, splits: [{ postId: "ghost", value: 100, absorbsRemainder: true }] })).toThrow(/post/i);
expect(() => actions.addRecurringCost(data, { ...rentInput, splits: [{ postId: data.posts[1]!.id, value: 100, absorbsRemainder: true }] })).toThrow(/archived/i);
const bill = actions.addRecurringCost(data, rentInput);
expect(() => actions.updateRecurringCost(data, bill.id, { splits: [{ postId: "ghost", value: 100, absorbsRemainder: true }] })).toThrow(/post/i);
expect(bill.splits).toEqual(rentInput.splits);
});
test("adding assigns an id and an order", () => {
const data = draft();
const first = actions.addRecurringCost(data, rentInput);
const second = actions.addRecurringCost(data, { ...rentInput, name: "Phone" });
expect(first.id).not.toBe(second.id);
expect(first.order).toBe(0);
expect(second.order).toBe(1);
expect(first.order).toBe(3);
expect(second.order).toBe(4);
expect(data.recurring.length).toBe(2);
});
@@ -753,8 +993,8 @@ describe("recurring costs", () => {
const second = actions.addRecurringCost(data, { ...rentInput, name: "Phone" });
actions.moveRecurringCost(data, second.id, -1);
expect(data.recurring.find((c) => c.id === second.id)!.order).toBe(0);
expect(data.recurring.find((c) => c.id === first.id)!.order).toBe(1);
expect(data.recurring.find((c) => c.id === second.id)!.order).toBe(3);
expect(data.recurring.find((c) => c.id === first.id)!.order).toBe(4);
});
});
@@ -764,6 +1004,7 @@ describe("confirmOccurrence", () => {
data.posts[0]!.id = "housing";
actions.addRecurringCost(data, {
name: "Rent",
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
@@ -848,6 +1089,7 @@ describe("editing a charge's date backwards re-anchors instead of throwing", ()
data.posts[0]!.id = "housing";
const car = actions.addRecurringCost(data, {
name: "Car insurance",
folderId: null,
archived: false,
amount: { amount: 900, currency: "DKK" },
startDate: "2026-08-14",
@@ -891,7 +1133,7 @@ describe("anchoring cannot change once a cost has charges", () => {
function seeded(anchoring: "calendar" | "lastCharge") {
const d = createSeedDataset("2026-09");
const post = d.posts[0]!;
const cost = actions.addRecurringCost(d, {
const cost = actions.addRecurringCost(d, { folderId: null,
name: "Bill", archived: false,
amount: { amount: 300, currency: "DKK" },
startDate: anchoring === "lastCharge" ? "2026-09-01" : "2026-09",
@@ -935,7 +1177,7 @@ describe("confirmOccurrence by anchoring", () => {
test("an anchored confirmation records NO occurrenceDate", () => {
const d = createSeedDataset("2026-09");
const post = d.posts[0]!;
const cost = actions.addRecurringCost(d, {
const cost = actions.addRecurringCost(d, { folderId: null,
name: "Phone", archived: false,
amount: { amount: 300, currency: "DKK" }, startDate: "2026-09-01",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "lastCharge",
@@ -959,7 +1201,7 @@ describe("addPurchase/updatePurchase enforce the source/occurrenceDate pairing",
function seeded() {
const d = createSeedDataset("2026-09");
const post = d.posts[0]!;
const calendarCost = actions.addRecurringCost(d, {
const calendarCost = actions.addRecurringCost(d, { folderId: null,
name: "Rent", archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-09",
@@ -967,7 +1209,7 @@ describe("addPurchase/updatePurchase enforce the source/occurrenceDate pairing",
anchoring: "calendar", splitMode: "percent",
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }],
});
const anchoredCost = actions.addRecurringCost(d, {
const anchoredCost = actions.addRecurringCost(d, { folderId: null,
name: "Phone", archived: false,
amount: { amount: 300, currency: "DKK" },
startDate: "2026-09-01",
@@ -1075,7 +1317,7 @@ describe("a charge for an anchored cost needs a day-granular date", () => {
// Starts BEFORE the charge, so the charge's own date is what the walk
// anchors on — `max(latest, startDate)` would otherwise hide the bug
// behind a day-granular startDate.
const cost = actions.addRecurringCost(d, {
const cost = actions.addRecurringCost(d, { folderId: null,
name: "Phone", archived: false,
amount: { amount: 300, currency: "DKK" },
startDate: "2026-08-04",
@@ -1205,7 +1447,7 @@ describe("a charge for an anchored cost needs a day-granular date", () => {
test("a month-only charge date on a CALENDAR cost is still fine — it settles a slot, it is not an anchor", () => {
const d = createSeedDataset("2026-09");
const post = d.posts[0]!;
const rent = actions.addRecurringCost(d, {
const rent = actions.addRecurringCost(d, { folderId: null,
name: "Rent", archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-09",
@@ -1236,6 +1478,7 @@ describe("runtime guards beyond what TypeScript's unions promise", () => {
}
const billInput = {
folderId: null,
name: "Bill", archived: false,
amount: { amount: 100, currency: "DKK" }, startDate: "2026-01-01",
recurrence: { kind: "everyNMonths" as const, n: 1 },
+214 -20
View File
@@ -13,6 +13,8 @@ import type {
Currency,
CurrencyDef,
Dataset,
BudgetFolder,
FolderId,
FxRate,
IsoDate,
Money,
@@ -48,6 +50,144 @@ function requirePost(draft: Dataset, postId: PostId): Post {
return post;
}
export type BudgetItemKey = { kind: "folder" | "post" | "recurring" | "purchase"; id: string };
type BudgetItem = BudgetFolder | Post | RecurringCost | Purchase;
type BudgetSibling = BudgetItemKey & { order: number };
const orderOf = (item: BudgetItem): number => "placement" in item && item.placement ? item.placement.order : (item as BudgetFolder | Post | RecurringCost).order;
const setOrder = (item: BudgetItem, order: number): void => { if ("placement" in item && item.placement) item.placement.order = order; else (item as BudgetFolder | Post | RecurringCost).order = order; };
function siblingRefs(draft: Dataset, parentId: FolderId | null): Array<{ key: BudgetItemKey; item: BudgetItem }> {
return [
...draft.folders.filter(item => item.parentId === parentId).map(item => ({ key: { kind: "folder" as const, id: item.id }, item })),
...draft.posts.filter(item => item.folderId === parentId).map(item => ({ key: { kind: "post" as const, id: item.id }, item })),
...draft.recurring.filter(item => !item.parentPostId && item.folderId === parentId).map(item => ({ key: { kind: "recurring" as const, id: item.id }, item })),
...draft.purchases.filter(item => item.placement?.folderId === parentId).map(item => ({ key: { kind: "purchase" as const, id: item.id }, item })),
].sort((a, b) => orderOf(a.item) - orderOf(b.item));
}
/** A clock-free, FX-free view of the shared order for Settings and placement. */
export function budgetSiblings(draft: Dataset, parentId: FolderId | null): BudgetSibling[] {
return siblingRefs(draft, parentId).map(({ key, item }) => ({ ...key, order: orderOf(item) }));
}
function nextSiblingOrder(draft: Dataset, parentId: FolderId | null): number {
const siblings = siblingRefs(draft, parentId);
if (!siblings.length) return 0;
const highest = orderOf(siblings[siblings.length - 1]!.item);
const next = highest + 1;
if (Number.isFinite(next) && next > highest) return next;
siblings.forEach(({ item }, order) => setOrder(item, order));
return siblings.length;
}
function requireFolder(draft: Dataset, id: FolderId): BudgetFolder {
const folder = draft.folders.find(item => item.id === id);
if (!folder) throw new Error(`Unknown folder: ${id}`);
return folder;
}
function requireFolderName(name: string): string {
const trimmed = name.trim();
if (!trimmed) throw new Error("Folder name cannot be blank");
return trimmed;
}
function requireBudgetItem(draft: Dataset, key: BudgetItemKey): BudgetItem {
if (key.kind === "folder") return requireFolder(draft, key.id);
if (key.kind === "post") return requirePost(draft, key.id);
if (key.kind === "recurring") return requireRecurringCost(draft, key.id);
if (key.kind === "purchase") return requirePurchase(draft, key.id);
throw new Error(`Unknown budget item kind: ${String(key.kind)}`);
}
function itemParent(key: BudgetItemKey, item: BudgetItem): FolderId | null {
return key.kind === "folder" ? (item as BudgetFolder).parentId : key.kind === "purchase" ? (item as Purchase).placement!.folderId : (item as Post | RecurringCost).folderId;
}
export function addBudgetFolder(draft: Dataset, name: string, parentId: FolderId | null): BudgetFolder {
const resolvedName = requireFolderName(name);
if (parentId !== null) requireFolder(draft, parentId);
const folder: BudgetFolder = { id: newId(), name: resolvedName, parentId, order: nextSiblingOrder(draft, parentId) };
draft.folders.push(folder);
return folder;
}
export function renameBudgetFolder(draft: Dataset, id: FolderId, name: string): void {
const folder = requireFolder(draft, id);
const resolvedName = requireFolderName(name);
folder.name = resolvedName;
}
/** Removes only the container; its direct children take its place in the parent. */
export function removeBudgetFolder(draft: Dataset, id: FolderId): void {
const folder = requireFolder(draft, id);
const parent = folder.parentId;
const siblings = siblingRefs(draft, parent);
const position = siblings.findIndex(entry => entry.key.kind === "folder" && entry.key.id === id);
const children = siblingRefs(draft, id);
for (const { key, item } of children) {
if (key.kind === "folder") (item as BudgetFolder).parentId = parent;
else if (key.kind === "purchase") (item as Purchase).placement!.folderId = parent;
else (item as Post | RecurringCost).folderId = parent;
}
draft.folders.splice(draft.folders.indexOf(folder), 1);
siblings.splice(position, 1, ...children);
siblings.forEach(({ item }, order) => setOrder(item, order));
}
export function placeBudgetItem(draft: Dataset, key: BudgetItemKey, destination: FolderId | null, index: number): void {
const item = requireBudgetItem(draft, key);
if (destination !== null) requireFolder(draft, destination);
if (key.kind === "folder") {
let cursor = destination;
while (cursor !== null) {
if (cursor === key.id) throw new Error("A folder cannot be moved into itself or a descendant");
cursor = requireFolder(draft, cursor).parentId;
}
}
const source = itemParent(key, item);
const sourceItems = siblingRefs(draft, source).filter(entry => !(entry.key.kind === key.kind && entry.key.id === key.id));
const destinationItems = source === destination ? sourceItems : siblingRefs(draft, destination);
if (!Number.isInteger(index) || index < 0 || index > destinationItems.length) {
throw new Error(`Invalid sibling index: ${index}`);
}
if (key.kind === "folder") (item as BudgetFolder).parentId = destination;
else if (key.kind === "purchase") (item as Purchase).placement!.folderId = destination;
else {
(item as Post | RecurringCost).folderId = destination;
if (key.kind === "recurring") {
const cost = item as RecurringCost;
if (cost.parentPostId) draft.recurring.filter(other => other.parentPostId === cost.parentPostId && other.id !== cost.id).sort((a, b) => a.order - b.order).forEach((other, position) => { other.order = position; });
delete cost.parentPostId;
}
}
destinationItems.splice(index, 0, { key, item });
if (source !== destination) sourceItems.forEach((entry, order) => setOrder(entry.item, order));
destinationItems.forEach((entry, order) => setOrder(entry.item, order));
}
export function placePurchase(draft: Dataset, purchaseId: PurchaseId, folderId: FolderId | null | undefined): void {
const purchase = requirePurchase(draft, purchaseId);
if (folderId !== undefined && folderId !== null) requireFolder(draft, folderId);
if (purchase.placement) {
siblingRefs(draft, purchase.placement.folderId).filter(entry => !(entry.key.kind === "purchase" && entry.key.id === purchaseId)).forEach(({ item }, order) => setOrder(item, order));
}
delete purchase.placement;
if (folderId === undefined) delete purchase.placement;
else purchase.placement = { folderId, order: nextSiblingOrder(draft, folderId) };
}
export function placeRecurringUnderPost(draft: Dataset, recurringId: RecurringCostId, postId: PostId): void {
requirePost(draft, postId);
const cost = requireRecurringCost(draft, recurringId);
const formerPostId = cost.parentPostId;
if (!cost.parentPostId) siblingRefs(draft, cost.folderId).filter(entry => !(entry.key.kind === "recurring" && entry.key.id === recurringId)).forEach(({ item }, order) => setOrder(item, order));
if (formerPostId) draft.recurring.filter(other => other.parentPostId === formerPostId && other.id !== recurringId).sort((a, b) => a.order - b.order).forEach((other, index) => { other.order = index; });
cost.folderId = null;
cost.parentPostId = postId;
cost.order = draft.recurring.filter(other => other.parentPostId === postId && other.id !== recurringId).length;
}
function requirePurchase(draft: Dataset, purchaseId: PurchaseId): Purchase {
const purchase = draft.purchases.find((p) => p.id === purchaseId);
if (!purchase) throw new Error(`Unknown purchase: ${purchaseId}`);
@@ -92,6 +232,14 @@ function roundSplits(
}));
}
function requireRecurringSplitDestinations(draft: Dataset, splits: Split[]): void {
if (splits.length === 0) throw new Error("A bill needs a destination post");
for (const split of splits) {
const post = requirePost(draft, split.postId);
if (post.archived) throw new Error(`Post ${post.name} is archived`);
}
}
function roundSchedule(draft: Dataset, schedule: Schedule | null): Schedule | null {
if (!schedule) return null;
return {
@@ -134,11 +282,13 @@ export function setRuleOverride(
month.ruleOverrides[postId] = roundRule(draft, rule);
}
export function addPost(draft: Dataset, name: string, currency: Currency): Post {
export function addPost(draft: Dataset, name: string, currency: Currency, folderId: FolderId | null = null): Post {
if (folderId !== null) requireFolder(draft, folderId);
const post: Post = {
id: newId(),
name,
order: draft.posts.length,
order: nextSiblingOrder(draft, folderId),
folderId,
archived: false,
currency,
rules: [],
@@ -147,13 +297,24 @@ export function addPost(draft: Dataset, name: string, currency: Currency): Post
return post;
}
/** Give a loose purchase an envelope without changing its amount or schedule. */
export function createPostForPurchase(draft: Dataset, purchaseId: PurchaseId): Post {
const purchase = requirePurchase(draft, purchaseId);
if (purchase.splits.length > 0) throw new Error("Purchase is already assigned to a post");
const post = addPost(draft, purchase.description.trim() || "Purchase", purchase.total.currency, purchase.placement?.folderId ?? null);
purchase.splits = [{ postId: post.id, value: 100, absorbsRemainder: true }];
purchase.splitMode = "percent";
return post;
}
export function updatePost(
draft: Dataset,
postId: PostId,
changes: Partial<Omit<Post, "id">>,
changes: Partial<Omit<Post, "id" | "folderId" | "order">>,
): void {
const post = requirePost(draft, postId);
const resolved: Partial<Omit<Post, "id">> = { ...changes };
if ("folderId" in changes || "order" in changes) throw new Error("Use placeBudgetItem to move a post");
const resolved: Partial<Omit<Post, "id" | "folderId" | "order">> = { ...changes };
Object.assign(post, resolved);
}
@@ -167,13 +328,12 @@ export function setPostArchived(
}
export function movePost(draft: Dataset, postId: PostId, direction: -1 | 1): void {
const ordered = [...draft.posts].sort((a, b) => a.order - b.order);
const index = ordered.findIndex((p) => p.id === postId);
const post = requirePost(draft, postId);
const ordered = budgetSiblings(draft, post.folderId);
const index = ordered.findIndex(item => item.kind === "post" && item.id === postId);
const target = index + direction;
if (index === -1 || target < 0 || target >= ordered.length) return;
const a = ordered[index]!;
const b = ordered[target]!;
[a.order, b.order] = [b.order, a.order];
if (target < 0 || target >= ordered.length) return;
placeBudgetItem(draft, { kind: "post", id: postId }, post.folderId, target);
}
/**
@@ -544,15 +704,18 @@ function requireStartDateGranularity(
export function addRecurringCost(
draft: Dataset,
cost: Omit<RecurringCost, "id" | "order">,
cost: Omit<RecurringCost, "id" | "order" | "parentPostId">,
): RecurringCost {
const anchoring = requireAnchoring(cost.anchoring);
const recurrence = requireRecurrence(cost.recurrence);
requireStartDateGranularity(recurrence, cost.startDate, anchoring);
if ("parentPostId" in cost) throw new Error("Use placeRecurringUnderPost to nest a recurring cost");
if (cost.folderId !== null) requireFolder(draft, cost.folderId);
requireRecurringSplitDestinations(draft, cost.splits);
const created: RecurringCost = {
...cost,
id: newId(),
order: draft.recurring.length,
order: nextSiblingOrder(draft, cost.folderId),
recurrence,
anchoring,
amount: roundMoneyValue(draft, cost.amount),
@@ -562,6 +725,36 @@ export function addRecurringCost(
return created;
}
/** Creates the dedicated envelope and its bill in one store mutation. */
export function addRecurringCostWithPost(
draft: Dataset,
cost: Omit<RecurringCost, "id" | "order" | "splits" | "splitMode" | "parentPostId">,
newPostName: string,
): RecurringCost {
const name = newPostName.trim();
if (!name) throw new Error("Post name cannot be blank");
const recurrence = requireRecurrence(cost.recurrence);
requireStartDateGranularity(recurrence, cost.startDate, requireAnchoring(cost.anchoring));
const post = addPost(draft, name, cost.amount.currency, cost.folderId);
const bill = addRecurringCost(draft, {
...cost,
splitMode: "percent",
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }],
});
placeRecurringUnderPost(draft, bill.id, post.id);
const monthly = recurrence.kind === "everyNMonths"
? bill.amount.amount / recurrence.n
: recurrence.kind === "everyNWeeks"
? bill.amount.amount * 52 / (12 * recurrence.n)
: bill.amount.amount * 365 / (12 * recurrence.n);
const factor = 10 ** draft.settings.digits;
const allocation = roundMoney(Math.ceil(monthly * factor) / factor, draft.settings.digits);
setRuleFrom(draft, post.id, monthOf(cost.startDate), {
kind: "fixed", amount: { amount: allocation, currency: cost.amount.currency },
});
return bill;
}
/**
* `archived` and `endedFrom` are excluded on purpose: they are a coherent
* pair (`archived` mirrors "`endedFrom` is set") maintained ONLY by
@@ -574,10 +767,11 @@ export function addRecurringCost(
export function updateRecurringCost(
draft: Dataset,
id: RecurringCostId,
changes: Partial<Omit<RecurringCost, "id" | "archived" | "endedFrom">>,
changes: Partial<Omit<RecurringCost, "id" | "archived" | "endedFrom" | "folderId" | "order" | "parentPostId">>,
): void {
const cost = requireRecurringCost(draft, id);
const resolved: Partial<Omit<RecurringCost, "id" | "archived" | "endedFrom">> = { ...changes };
if ("folderId" in changes || "order" in changes || "parentPostId" in changes) throw new Error("Use placement actions to move a recurring cost");
const resolved: Partial<Omit<RecurringCost, "id" | "archived" | "endedFrom" | "folderId" | "order">> = { ...changes };
if (changes.anchoring) {
requireAnchoring(changes.anchoring);
@@ -622,6 +816,7 @@ export function updateRecurringCost(
resolved.amount = roundMoneyValue(draft, changes.amount);
}
if (changes.splits) {
requireRecurringSplitDestinations(draft, changes.splits);
// Either field may be absent from a partial update; the stored cost
// supplies whichever one is, so the mode and currency always agree with
// the values being rounded.
@@ -641,13 +836,12 @@ export function moveRecurringCost(
id: RecurringCostId,
direction: -1 | 1,
): void {
const ordered = [...draft.recurring].sort((a, b) => a.order - b.order);
const index = ordered.findIndex((c) => c.id === id);
const cost = requireRecurringCost(draft, id);
const ordered = budgetSiblings(draft, cost.folderId);
const index = ordered.findIndex(item => item.kind === "recurring" && item.id === id);
const target = index + direction;
if (index === -1 || target < 0 || target >= ordered.length) return;
const a = ordered[index]!;
const b = ordered[target]!;
[a.order, b.order] = [b.order, a.order];
if (target < 0 || target >= ordered.length) return;
placeBudgetItem(draft, { kind: "recurring", id }, cost.folderId, target);
}
/**
+106 -5
View File
@@ -4,6 +4,8 @@ import { migrate, UnsupportedSchemaError } from "./migrations.ts";
import { figuresFor, foldBalances } from "../domain/fold.ts";
import { occurrencesByMonth } from "../domain/occurrences.ts";
import { parseDatasetJson } from "../export/json.ts";
import { budgetTree } from "../domain/budgetTree.ts";
import { monthView } from "../domain/views.ts";
describe("migrate", () => {
test("passes a current-version dataset through unchanged", () => {
@@ -195,7 +197,7 @@ describe("v2 -> v3: currencies become data", () => {
test("nothing else about the dataset is touched", () => {
const before = v2();
const migrated = migrate(structuredClone(before));
expect(migrated.posts).toEqual(before.posts);
expect(migrated.posts.map(({ folderId, ...post }) => post)).toEqual(before.posts);
expect(migrated.months).toEqual(before.months);
// Sterling's rate arrives later, in 4 -> 5; nothing this step does touches
// the rate table.
@@ -468,7 +470,7 @@ describe("6 -> 7: recurring costs join the dataset", () => {
const v6: any = { ...createSeedDataset("2026-09"), recurring: existing };
v6.settings = { ...v6.settings, schemaVersion: 6 };
expect(migrate(v6).recurring).toEqual(existing);
expect(migrate(v6).recurring).toEqual([{ ...existing[0], order: 3, folderId: null }]);
});
test("NO figure changes: every post in every month folds identically", () => {
@@ -488,7 +490,10 @@ describe("6 -> 7: recurring costs join the dataset", () => {
});
});
describe("7 -> 8: the anchored model", () => {
// The anchored-model step is 10 -> 11; these fixtures are schema 7 because that
// is the shape a real pre-anchored backup has, and migrating them walks every
// step in between exactly as a restore would.
describe("10 -> 11: the anchored model", () => {
function v7(recurring: any[], purchases: any[]): any {
const base = createSeedDataset("2026-09");
return {
@@ -558,7 +563,7 @@ describe("7 -> 8: the anchored model", () => {
});
/**
* The half of 7 -> 8 that matters most, because it is the owner's OWN data.
* The half of 10 -> 11 that matters most, because it is the owner's OWN data.
*
* An anchored cost has no slots: `anchoredOccurrences` takes the latest
* charge's own `Purchase.date` as its anchor and steps forward from it. Under
@@ -572,7 +577,7 @@ describe("7 -> 8: the anchored model", () => {
* first load. So these tests migrate and then FOLD, rather than only
* inspecting the shape.
*/
describe("7 -> 8 expands a month-only charge date on an anchored cost", () => {
describe("10 -> 11 expands a month-only charge date on an anchored cost", () => {
/**
* A schema-7 dataset the way the app itself would have written one: real
* posts, a monthly phone plan anchored to its last charge with a
@@ -706,3 +711,99 @@ describe("7 -> 8 expands a month-only charge date on an anchored cost", () => {
expect(() => foldBalances(restored, "2026-12")).not.toThrow();
});
});
test("v7 migration preserves records and assigns mixed order", () => {
const data: any = createSeedDataset("2026-09");
data.settings.schemaVersion = 7;
delete data.folders;
data.posts = [
{ ...data.posts[0], id: "later", order: 9, rules: [{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 20, currency: "DKK" } } }] },
{ ...data.posts[1], id: "first", order: 2 },
].map(({ folderId, ...post }: any) => post);
data.recurring = [
{ id: "bill-later", name: "Later", order: 8, splits: [{ postId: "first", value: 100, absorbsRemainder: true }] },
{ id: "bill-first", name: "First", order: 1, splits: [{ postId: "later", value: 100, absorbsRemainder: true }] },
];
data.purchases = [{ id: "purchase", splits: [{ postId: "later", value: 100, absorbsRemainder: true }] }];
const before = structuredClone(data);
const migrated = migrate(data);
expect(migrated.folders).toEqual([]);
expect(migrated.posts.map((post) => [post.id, post.order, post.folderId])).toEqual([
["later", 1, null], ["first", 0, null],
]);
expect(migrated.recurring.map((bill) => [bill.id, bill.order, bill.folderId, bill.parentPostId])).toEqual([
["bill-later", 0, null, "first"], ["bill-first", 0, null, "later"],
]);
expect(new Set(migrated.posts.map((item) => item.order)).size).toBe(2);
expect(migrated.posts[0]!.rules).toEqual(before.posts[0].rules);
expect(migrated.recurring.map((bill) => bill.splits)).toEqual(before.recurring.map((bill: any) => bill.splits));
expect(migrated.purchases).toEqual(before.purchases);
});
test("v8 migration preserves existing purchase and post data", () => {
const data = createSeedDataset("2026-09");
data.settings.schemaVersion = 8;
const before = structuredClone(data);
const migrated = migrate(data);
expect(migrated.settings.schemaVersion).toBe(SCHEMA_VERSION);
expect(migrated.posts).toEqual(before.posts);
expect(migrated.purchases).toEqual(before.purchases);
expect(migrated.recurring).toEqual(before.recurring);
});
for (const oldVersion of [8, 9]) test(`v${oldVersion} single-post unfiled bills become children of their posts`, () => {
const data = createSeedDataset("2026-09");
data.settings.schemaVersion = oldVersion;
const games = data.posts[0]!;
data.recurring = ["Star Rail Pass", "Genshin Pass"].map((name, index) => ({
id: `bill-${index}`, name, order: 3 + index, folderId: null, archived: false,
amount: { amount: 35, currency: "DKK" }, startDate: "2026-10",
recurrence: { kind: "everyNMonths" as const, n: 1 }, anchoring: "calendar" as const,
splitMode: "percent" as const,
splits: [{ postId: games.id, value: 100, absorbsRemainder: true }],
}));
const migrated = migrate(data);
expect(migrated.recurring.map(bill => [bill.name, bill.parentPostId, bill.order])).toEqual([
["Star Rail Pass", games.id, 0], ["Genshin Pass", games.id, 1],
]);
expect(migrated.recurring.every(bill => bill.folderId === null)).toBe(true);
const imported = parseDatasetJson(JSON.stringify(data));
const gamesRow = budgetTree(imported, "2026-10", monthView(imported, "2026-10").rows)
.unfiled.find(node => node.kind === "post" && node.row.post.id === games.id);
expect(gamesRow?.kind).toBe("post");
if (gamesRow?.kind === "post") expect(gamesRow.children.map(node => node.kind === "bill" ? node.cost.name : "other"))
.toEqual(["Star Rail Pass", "Genshin Pass"]);
});
test("v9 migration keeps explicitly filed and multi-post bills where they are", () => {
const data = createSeedDataset("2026-09");
data.settings.schemaVersion = 9;
data.folders = [{ id: "filed", name: "Bills", parentId: null, order: 3 }];
const base = { archived: false, amount: { amount: 20, currency: "DKK" }, startDate: "2026-09", recurrence: { kind: "everyNMonths" as const, n: 1 }, anchoring: "calendar" as const, splitMode: "percent" as const };
data.recurring = [
{ ...base, id: "filed-bill", name: "Filed", folderId: "filed", order: 0, splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }] },
{ ...base, id: "shared-bill", name: "Shared", folderId: null, order: 4, splits: [{ postId: data.posts[0]!.id, value: 50, absorbsRemainder: true }, { postId: data.posts[1]!.id, value: 50, absorbsRemainder: false }] },
];
const migrated = migrate(data);
expect(migrated.recurring.map(bill => [bill.parentPostId, bill.folderId, bill.order])).toEqual([
[undefined, "filed", 0], [undefined, null, 4],
]);
});
test("v9 migration appends old flat bills after already nested bills", () => {
const data = createSeedDataset("2026-09");
data.settings.schemaVersion = 9;
const postId = data.posts[0]!.id;
const base = { archived: false, folderId: null, amount: { amount: 35, currency: "DKK" }, startDate: "2026-09", recurrence: { kind: "everyNMonths" as const, n: 1 }, anchoring: "calendar" as const, splitMode: "percent" as const, splits: [{ postId, value: 100, absorbsRemainder: true }] };
data.recurring = [
{ ...base, id: "new", name: "Already nested", order: 0, parentPostId: postId },
{ ...base, id: "old", name: "Old flat", order: 3 },
];
const migrated = migrate(data);
expect(migrated.recurring.map(bill => [bill.parentPostId, bill.order])).toEqual([
[postId, 0], [postId, 1],
]);
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
});
+51 -4
View File
@@ -194,7 +194,54 @@ const MIGRATIONS: Array<(data: any) => any> = [
recurring: Array.isArray(data.recurring) ? data.recurring : [],
}),
// 7 -> 8: recurring costs split into two projection models.
// 7 -> 8: posts and recurring bills gain independent locations in a shared
// folder tree. Preserve each type's former display order, with posts first.
// The migration writes no financial data and reads no live default.
(data: any) => {
const posts = Array.isArray(data.posts) ? data.posts : [];
const recurring = Array.isArray(data.recurring) ? data.recurring : [];
const postOrders = new Map(
[...posts].sort((a: any, b: any) => a.order - b.order).map((post: any, index) => [post, index]),
);
const billOrders = new Map(
[...recurring].sort((a: any, b: any) => a.order - b.order).map((bill: any, index) => [bill, posts.length + index]),
);
return {
...data,
settings: { ...data.settings, schemaVersion: 8 },
folders: [],
posts: posts.map((post: any) => ({ ...post, order: postOrders.get(post), folderId: null })),
recurring: recurring.map((bill: any) => ({ ...bill, order: billOrders.get(bill), folderId: null })),
};
},
// 8 -> 9: placement fields are optional, so existing rows stay where they are.
(data: any) => ({ ...data, settings: { ...data.settings, schemaVersion: 9 } }),
// 9 -> 10: existing unfiled bills with one destination gain the same default
// nesting as newly created bills. Splits and occurrence identity are untouched.
(data: any) => {
const postIds = new Set((data.posts ?? []).map((post: any) => post.id));
const nextOrder = new Map<string, number>();
const recurring = [...(data.recurring ?? [])].sort((a: any, b: any) => a.order - b.order);
for (const bill of recurring) {
if (bill.parentPostId) nextOrder.set(bill.parentPostId, Math.max(nextOrder.get(bill.parentPostId) ?? 0, bill.order + 1));
}
const nested = new Map(recurring.map((bill: any) => {
const postId = bill.folderId === null && !bill.parentPostId && bill.splits?.length === 1
? bill.splits[0].postId : undefined;
if (!postId || !postIds.has(postId)) return [bill, bill];
const order = nextOrder.get(postId) ?? 0;
nextOrder.set(postId, order + 1);
return [bill, { ...bill, parentPostId: postId, order }];
}));
return {
...data,
settings: { ...data.settings, schemaVersion: 10 },
recurring: (data.recurring ?? []).map((bill: any) => nested.get(bill)),
};
},
// 10 -> 11: recurring costs split into two projection models.
//
// A `lastCharge` cost no longer has a grid of slots — its next due date is
// one cycle after the latest actual charge — so `occurrenceDate` on its
@@ -204,9 +251,9 @@ const MIGRATIONS: Array<(data: any) => any> = [
// Day precision also now requires a real day, and on BOTH sides of an
// anchored cost. Its `startDate`, if it was month-only, is expanded to the
// 1st. So is the `date` of every purchase charged to it — an anchored cost
// has no slots, so `anchoredOccurrences` steps forward from the latest
// has no slots, so the anchored walk steps forward from the latest
// charge's own date, and a month-only one there throws out of the first
// fold after the upgrade. Those dates are REAL in schema 7: the old
// fold after the upgrade. Those dates are REAL before this step: the old
// `confirmOccurrence` wrote `date: overrides.date ?? occurrenceDate`, so a
// month-granular monthly cost's one-click confirmations were stored
// month-only. That is a phone bill confirmed from the Expected band — the
@@ -232,7 +279,7 @@ const MIGRATIONS: Array<(data: any) => any> = [
);
return {
...data,
settings: { ...data.settings, schemaVersion: 8 },
settings: { ...data.settings, schemaVersion: 11 },
recurring: recurring.map((cost: any) => {
if (cost?.anchoring !== "lastCharge") return cost;
if (cost?.recurrence?.kind !== "everyNMonths") return cost;
+116
View File
@@ -181,6 +181,7 @@ describe("reset", () => {
id: "extra",
name: "Extra",
order: 9,
folderId: null,
archived: false,
currency: "DKK",
rules: [],
@@ -285,3 +286,118 @@ describe("seed rates", () => {
expect(store.get().fxRates).toEqual([]);
});
});
describe("session history", () => {
test("undo and redo persist complete prior snapshots in order", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
const originalId = store.get().posts[0]!.id;
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
await store.mutate(draft => { draft.posts[0]!.name = "Second"; });
expect(store.getHistory()).toEqual({ canUndo: true, canRedo: false });
expect(await store.undo()).toBe(true);
expect(store.get().posts[0]).toMatchObject({ id: originalId, name: "First" });
expect(fake.stored!.posts[0]!.name).toBe("First");
expect(await store.undo()).toBe(true);
expect(store.get().posts[0]!.name).toBe("Video Games");
expect(await store.undo()).toBe(false);
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: true });
expect(await store.redo()).toBe(true);
expect(await store.redo()).toBe(true);
expect(await store.redo()).toBe(false);
expect(store.get().posts[0]!.name).toBe("Second");
expect(fake.stored!.posts[0]!.name).toBe("Second");
});
test("an unchanged edit creates no step and does not discard redo", async () => {
const store = createSnapshotStore(fakePersistence(createSeedDataset("2026-09")).persistence, "2026-09");
await store.load();
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
await store.undo();
await store.mutate(draft => { draft.posts[0]!.name = "Video Games"; });
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: true });
expect(await store.redo()).toBe(true);
expect(store.get().posts[0]!.name).toBe("First");
});
test("a new saved edit after undo discards redo", async () => {
const store = createSnapshotStore(fakePersistence(createSeedDataset("2026-09")).persistence, "2026-09");
await store.load();
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
await store.undo();
await store.mutate(draft => { draft.posts[1]!.name = "Changed"; });
expect(store.getHistory()).toEqual({ canUndo: true, canRedo: false });
expect(await store.redo()).toBe(false);
});
test("a rejected undo leaves data and history available for retry", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
fake.persistence.write = async () => { throw new Error("disk full"); };
await expect(store.undo()).rejects.toThrow("disk full");
expect(store.get().posts[0]!.name).toBe("First");
expect(store.getHistory()).toEqual({ canUndo: true, canRedo: false });
fake.persistence.write = async () => {};
expect(await store.undo()).toBe(true);
});
test("a rejected new edit preserves redo", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
await store.undo();
fake.persistence.write = async () => { throw new Error("disk full"); };
await expect(store.mutate(draft => { draft.posts[1]!.name = "Fail"; })).rejects.toThrow("disk full");
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: true });
});
test("undo runs after already queued edits", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
const first = store.mutate(draft => { draft.posts[0]!.name = "First"; });
const second = store.mutate(draft => { draft.posts[0]!.name = "Second"; });
const undo = store.undo();
await Promise.all([first, second, undo]);
expect(store.get().posts[0]!.name).toBe("First");
expect(fake.stored!.posts[0]!.name).toBe("First");
});
test("import and reset clear history only after their writes succeed", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
const incoming = createSeedDataset("2020-01");
fake.persistence.write = async () => { throw new Error("disk full"); };
await expect(store.replace(incoming)).rejects.toThrow("disk full");
expect(store.getHistory().canUndo).toBe(true);
fake.persistence.write = async () => {};
await store.replace(incoming);
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: false });
await store.mutate(draft => { draft.posts[0]!.name = "New"; });
await store.reset();
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: false });
});
test("history is limited to fifty edits and disappears on reload", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
for (let n = 1; n <= 51; n++) {
await store.mutate(draft => { draft.posts[0]!.name = `Edit ${n}`; });
}
for (let n = 0; n < 50; n++) expect(await store.undo()).toBe(true);
expect(store.get().posts[0]!.name).toBe("Edit 1");
expect(await store.undo()).toBe(false);
const reopened = createSnapshotStore(fake.persistence, "2026-09");
await reopened.load();
expect(reopened.get().posts[0]!.name).toBe("Edit 1");
expect(reopened.getHistory()).toEqual({ canUndo: false, canRedo: false });
});
});
+76 -13
View File
@@ -8,12 +8,20 @@ export interface Persistence {
export type Listener = () => void;
export interface HistoryState {
canUndo: boolean;
canRedo: boolean;
}
export interface SnapshotStore {
load(): Promise<void>;
get(): Dataset;
mutate(fn: (draft: Dataset) => void): Promise<void>;
replace(dataset: Dataset): Promise<void>;
reset(): Promise<void>;
getHistory(): HistoryState;
undo(): Promise<boolean>;
redo(): Promise<boolean>;
subscribe(listener: Listener): () => void;
}
@@ -30,6 +38,10 @@ export function createSnapshotStore(
): SnapshotStore {
let snapshot: Dataset | null = null;
const listeners = new Set<Listener>();
const undoStack: Dataset[] = [];
const redoStack: Dataset[] = [];
let historyState: HistoryState = { canUndo: false, canRedo: false };
const historyLimit = 50;
// All mutations run through this chain, one at a time, so a mutation
// always clones from the snapshot the previous mutation committed rather
@@ -51,42 +63,81 @@ export function createSnapshotStore(
for (const listener of listeners) listener();
}
function updateHistoryState() {
historyState = { canUndo: undoStack.length > 0, canRedo: redoStack.length > 0 };
}
function pushHistory(stack: Dataset[], dataset: Dataset) {
stack.push(dataset);
if (stack.length > historyLimit) stack.shift();
}
function clearHistory() {
undoStack.length = 0;
redoStack.length = 0;
updateHistoryState();
}
function get(): Dataset {
if (!snapshot) throw new Error("Snapshot store is not loaded yet");
return snapshot;
}
async function commit(next: Dataset) {
async function commit(next: Dataset, afterWrite?: () => void) {
// Write first: if persistence fails, the in-memory snapshot must not have
// moved, or the UI would show data that was never saved.
await persistence.write(next);
snapshot = next;
afterWrite?.();
notify();
}
function travel(from: Dataset[], to: Dataset[]): Promise<boolean> {
return enqueue(async () => {
const target = from.at(-1);
if (!target) return false;
const previous = get();
await commit(structuredClone(target), () => {
from.pop();
pushHistory(to, previous);
updateHistoryState();
});
return true;
});
}
return {
async load() {
const stored = await persistence.read();
if (stored) {
snapshot = stored;
notify();
return;
}
await commit(createSeedDataset(currentMonth, seedRates));
load() {
return enqueue(async () => {
const stored = await persistence.read();
if (stored) {
snapshot = stored;
clearHistory();
notify();
return;
}
await commit(createSeedDataset(currentMonth, seedRates), clearHistory);
});
},
get,
mutate(fn) {
return enqueue(async () => {
const draft = structuredClone(get());
const previous = get();
const draft = structuredClone(previous);
fn(draft);
await commit(draft);
if (JSON.stringify(draft) === JSON.stringify(previous)) return;
await commit(draft, () => {
pushHistory(undoStack, previous);
redoStack.length = 0;
updateHistoryState();
});
});
},
replace(dataset) {
return enqueue(() => commit(dataset));
return enqueue(() => commit(structuredClone(dataset), clearHistory));
},
/**
@@ -99,7 +150,19 @@ export function createSnapshotStore(
* mutation is still in flight cannot be overtaken by it.
*/
reset() {
return enqueue(() => commit(createSeedDataset(currentMonth, seedRates)));
return enqueue(() => commit(createSeedDataset(currentMonth, seedRates), clearHistory));
},
getHistory() {
return historyState;
},
undo() {
return travel(undoStack, redoStack);
},
redo() {
return travel(redoStack, undoStack);
},
subscribe(listener) {
+3 -1
View File
@@ -10,6 +10,7 @@ import { SettingsRoute } from "./routes/SettingsRoute.tsx";
import { UpdatePrompt } from "./components/UpdatePrompt.tsx";
import { RouteErrorBoundaryReset } from "./components/ErrorBoundary.tsx";
import { ThemeToggle } from "./components/ThemeToggle.tsx";
import { HistoryControls } from "./components/HistoryControls.tsx";
/**
* One nav item. The active route is marked with weight and an accent
@@ -44,7 +45,7 @@ export function AppRoutes() {
it. Translucent with a blur so the paper ground still reads as one
continuous sheet scrolling underneath. */}
<header className="sticky top-0 z-30 border-b border-budget-rule bg-budget-paper/85 backdrop-blur-md">
<div className="mx-auto flex max-w-6xl items-center gap-1 px-4 py-2.5 sm:px-6">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-x-1 gap-y-1 px-4 py-2.5 sm:px-6">
<span className="mr-3 hidden text-sm font-semibold tracking-tight text-budget-ink sm:block">
Budget<span className="text-budget-accent">&nbsp;2.0</span>
</span>
@@ -54,6 +55,7 @@ export function AppRoutes() {
<NavItem to="/summary">Summary</NavItem>
</nav>
<div className="ml-auto flex items-center gap-1">
<HistoryControls />
<ThemeToggle />
<NavItem to="/settings">Settings</NavItem>
</div>
+16 -1
View File
@@ -1,5 +1,15 @@
import { test, expect } from "bun:test";
import { describeBuild, readBuildVersionEnv } from "./buildVersion.ts";
import { commitHref, describeBuild, readBuildVersionEnv } from "./buildVersion.ts";
test("a full commit SHA links to the exact source revision", () => {
expect(commitHref("61e7093c36f9a0111a5f1c8776aba4894c88ebc4"))
.toBe("https://github.com/StereotypicalCat/budget2.0/commit/61e7093c36f9a0111a5f1c8776aba4894c88ebc4");
expect(commitHref("61e7093c36f9a0111a5f1c8776aba4894c88ebc4", "https://gitea.lucaswinther.info/lucasw89/budget2.0"))
.toBe("https://gitea.lucaswinther.info/lucasw89/budget2.0/commit/61e7093c36f9a0111a5f1c8776aba4894c88ebc4");
expect(commitHref("1788307200000")).toBeUndefined();
expect(commitHref("v1.4.0")).toBeUndefined();
expect(commitHref("61e7093c36f9a0111a5f1c8776aba4894c88ebc4-dirty")).toBeUndefined();
});
test("an absent stamp reads as development, not as a blank or a lie", () => {
expect(describeBuild(undefined)).toBe("development");
@@ -20,6 +30,11 @@ test("a commit sha is shortened the way git shortens one", () => {
expect(describeBuild("61e7093")).toBe("61e7093");
});
test("a local build with uncommitted changes is marked as such", () => {
expect(describeBuild("61e7093c36f9a0111a5f1c8776aba4894c88ebc4-dirty"))
.toBe("61e7093-dirty");
});
test("a version someone chose is printed as given", () => {
// A sha, a tag and a release number must survive untouched: guessing at a
// format the builder did not ask for is worse than showing their string.
+23 -2
View File
@@ -20,7 +20,7 @@ export function readBuildVersionEnv(): string | undefined {
}
}
/** A millisecond epoch, which is what build.ts falls back to. */
/** A millisecond epoch, used when no Git revision was supplied or available. */
const EPOCH_MS = /^\d{10,}$/;
/**
@@ -34,7 +34,7 @@ const LONG_SHA = /^(?=.*[a-f])[0-9a-f]{12,}$/i;
* `BUILD_VERSION` may be anything the person building chose — a git sha, a tag,
* a release number — so an unrecognised value is printed as given rather than
* reformatted into something it is not. The one shape worth translating is
* build.ts's own default, a `Date.now()` timestamp, because "1756809600000"
* build.ts's source-archive fallback, a `Date.now()` timestamp, because "1756809600000"
* tells a reader nothing and "2026-09-02" tells them what they wanted to know.
*
* ISO rather than a locale format: this string appears in bug reports against
@@ -46,8 +46,29 @@ export function describeBuild(raw: string | undefined): string {
const at = new Date(Number(raw));
if (!Number.isNaN(at.getTime())) return at.toISOString().slice(0, 10);
}
if (LONG_SHA.test(raw.replace(/-dirty$/, "")) && raw.endsWith("-dirty")) return `${raw.slice(0, 7)}-dirty`;
if (LONG_SHA.test(raw)) return raw.slice(0, 7);
return raw;
}
export const BUILD_VERSION = describeBuild(readBuildVersionEnv());
/** The build injects its source origin; dev mode uses the public mirror. */
export function readSourceRepositoryEnv(): string | undefined {
try {
return process.env.BUN_PUBLIC_SOURCE_REPOSITORY;
} catch {
return undefined;
}
}
export const SOURCE_REPOSITORY = readSourceRepositoryEnv() ?? "https://github.com/StereotypicalCat/budget2.0";
/** A build with an exact revision can link directly to its source. */
export function commitHref(raw: string | undefined, repository = SOURCE_REPOSITORY): string | undefined {
return raw && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(raw)
? `${repository}/commit/${raw}`
: undefined;
}
export const COMMIT_HREF = commitHref(readBuildVersionEnv());
+5 -6
View File
@@ -76,6 +76,11 @@ describe("lineToDraft", () => {
});
describe("validateBulk", () => {
test("allows lines with no post", () => {
const loose = draft({ postId: "", lines: [line("Milk", 25)] });
expect(validateBulk(loose)).toEqual([]);
expect(toPurchases(loose)[0]!.splits).toEqual([]);
});
test("a well-formed batch has no errors", () => {
expect(validateBulk(draft({ lines: [line("Milk", 25), line("Bread", 30)] }))).toEqual([]);
});
@@ -98,12 +103,6 @@ describe("validateBulk", () => {
expect(errors.some((e) => e.includes("1") && /description/i.test(e))).toBe(true);
});
test("requires a post", () => {
expect(validateBulk(draft({ postId: "", lines: [line("Milk", 25)] }))).toContain(
"Choose a post for these lines.",
);
});
test("does NOT reject a negative amount — over-budget and refunds are allowed", () => {
expect(validateBulk(draft({ lines: [line("Refund", -25)] }))).toEqual([]);
});
+4 -6
View File
@@ -8,8 +8,9 @@ import { toPurchase, type PurchaseDraft } from "./purchaseForm.ts";
*
* Date, post and currency are shared across the batch and set once; a line is
* just a description and an amount. Each line becomes its own Purchase landing
* wholly in the shared post, built through the SAME `toPurchase` the single
* form uses, so there is one definition of what a purchase is.
* wholly in the shared post, or unassigned when no post is selected. The
* SAME `toPurchase` the single form uses builds it, so there is one definition
* of what a purchase is.
*/
export interface BulkLine {
@@ -85,7 +86,7 @@ export function lineToDraft(draft: BulkDraft, line: BulkLine): PurchaseDraft {
amount: line.amount,
currency: draft.currency,
splitMode: "percent",
splits: [{ postId: draft.postId, value: 100, absorbsRemainder: true }],
splits: draft.postId ? [{ postId: draft.postId, value: 100, absorbsRemainder: true }] : [],
plan: null,
};
}
@@ -99,9 +100,6 @@ export function validateBulk(draft: BulkDraft): string[] {
const errors: string[] = [];
const filled = filledLines(draft);
if (draft.postId === "") {
errors.push("Choose a post for these lines.");
}
if (filled.length === 0) {
errors.push("Add at least one line.");
}
+104
View File
@@ -0,0 +1,104 @@
import { expect, test } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { act } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router";
import { createSeedDataset } from "../../domain/seed.ts";
import { addBudgetFolder, addPurchase, addRecurringCost, confirmOccurrence, placeRecurringUnderPost } from "../../store/actions.ts";
import { monthView } from "../../domain/views.ts";
import { store } from "../../store/index.ts";
import { PostTableLegend } from "./PostTable.tsx";
import { BudgetTree } from "./BudgetTree.tsx";
function setup(run: (container: HTMLDivElement, billId: string) => void, confirm = false) {
const data = createSeedDataset("2026-09");
const housing = addBudgetFolder(data, "Housing", null);
const utilities = addBudgetFolder(data, "Utilities", housing.id); utilities.order = 0;
const post = data.posts[0]!; post.name = "Rent post"; post.folderId = housing.id; post.order = 1;
const bill = addRecurringCost(data, { name: "Rent bill", folderId: housing.id, archived: false, amount: { amount: 8000, currency: "DKK" }, startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 18 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: post.id, value: 100, absorbsRemainder: true }] }); bill.order = 2;
addRecurringCost(data, { ...bill, name: "Ended bill", folderId: null, endedFrom: "2026-08" });
if (confirm) confirmOccurrence(data, bill.id, "2026-09", { date: "2026-09-01" });
const original = { get: store.get, subscribe: store.subscribe }; store.get = () => data; store.subscribe = () => () => {};
const container = document.createElement("div"); document.body.append(container); const root = createRoot(container);
try { act(() => root.render(<MemoryRouter><PostTableLegend baseCurrency="DKK" /><BudgetTree monthId="2026-09" rows={monthView(data, "2026-09").rows} /></MemoryRouter>)); run(container, bill.id); }
finally { act(() => root.unmount()); container.remove(); Object.assign(store, original); }
}
function housing(container: HTMLDivElement) { return container.querySelector('button[aria-label="Housing"]') as HTMLButtonElement; }
test("collapsed folder keeps Bills due", () => setup(container => {
expect(housing(container).getAttribute("aria-expanded")).toBe("false");
expect(housing(container).parentElement!.textContent).toContain("8,000");
expect(housing(container).parentElement!.textContent).toContain("Bills due");
expect(container.querySelector('[data-budget-name="Rent post"]')).toBeNull();
}));
test("expanded tree preserves mixed order", () => setup(container => {
act(() => housing(container).click());
for (const scope of [container.querySelector("table")!, container.querySelector("ul")!]) {
const names = Array.from(scope.querySelectorAll("[data-budget-item]")).map(item => item.getAttribute("data-budget-name"));
expect(names.slice(0, 4)).toEqual(["Housing", "Utilities", "Rent post", "Rent bill"]);
}
expect((container.querySelector('table [data-budget-name="Rent post"] td') as HTMLElement).style.paddingInlineStart).toBe("16px");
expect((container.querySelector('ul [data-budget-name="Rent post"]') as HTMLElement).style.paddingInlineStart).toBe("16px");
expect(container.textContent).toContain("Unfiled"); expect(container.textContent).toContain("Social");
expect(container.querySelector("table > div")).toBeNull();
}));
test("bill edit reaches its settings row", () => setup((container, billId) => {
act(() => housing(container).click());
expect(container.querySelector(`[data-budget-item="${billId}"] a`)!.getAttribute("href")).toBe(`/settings#bill-${billId}`);
expect(container.textContent).toContain("Ended");
expect(container.textContent).toContain("Next 2028-03");
}, true));
test("legend scopes base currency to post figures and identifies bill currencies", () => setup(container => {
const legend = container.querySelector("span.flex.flex-wrap")!;
expect(legend.textContent).toContain("Post figures in DKK.");
expect(legend.textContent).toContain("Bill prices show their own currency.");
expect(legend.textContent).toContain("Projected subtracts bills not yet confirmed.");
expect(legend.textContent).not.toContain("All figures");
}));
test("bills beneath a post are hidden until its post is expanded", () => {
const data = createSeedDataset("2026-09");
const games = data.posts[0]!;
const bill = addRecurringCost(data, { name: "Star Rail Pass", folderId: null, archived: false, amount: { amount: 35, currency: "DKK" }, startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: games.id, value: 100, absorbsRemainder: true }] });
placeRecurringUnderPost(data, bill.id, games.id);
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div"); document.body.append(container);
const root = createRoot(container);
try {
act(() => root.render(<MemoryRouter><BudgetTree monthId="2026-09" rows={monthView(data, "2026-09").rows} /></MemoryRouter>));
const controls = Array.from(container.querySelectorAll(`button[aria-label="Bills under ${games.name}"]`)) as HTMLButtonElement[];
expect(controls).toHaveLength(2);
expect(controls[0]!.getAttribute("aria-expanded")).toBe("false");
expect(container.querySelector(`[data-budget-item="${bill.id}"]`)).toBeNull();
act(() => controls[0]!.click());
expect(controls[0]!.getAttribute("aria-expanded")).toBe("true");
expect(container.querySelectorAll(`[data-budget-item="${bill.id}"]`)).toHaveLength(2);
} finally {
act(() => root.unmount()); container.remove(); Object.assign(store, original);
}
});
test("Unassigned is a permanent Budget section with a create-post action", () => {
const data = createSeedDataset("2026-09");
const purchase = addPurchase(data, { date: "2026-09", description: "Train ticket", total: { amount: 50, currency: "DKK" }, splitMode: "percent", splits: [], schedule: null });
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div"); document.body.append(container);
const root = createRoot(container);
try {
act(() => root.render(<MemoryRouter><BudgetTree monthId="2026-09" rows={monthView(data, "2026-09").rows} /></MemoryRouter>));
for (const scope of [container.querySelector("table")!, container.querySelector("ul")!]) {
expect(scope.textContent).toContain("Unassigned");
const row = scope.querySelector(`[data-budget-item="${purchase.id}"]`)!;
expect(row.textContent).toContain("Train ticket");
expect(row.querySelector("button[aria-label='Create post for Train ticket']")).not.toBeNull();
}
} finally {
act(() => root.unmount()); container.remove(); Object.assign(store, original);
}
});
+64
View File
@@ -0,0 +1,64 @@
import { useState, type ReactNode } from "react";
import { Link } from "react-router";
import { ChevronDown, ChevronRight } from "lucide-react";
import { budgetTree, type BudgetNode } from "../../domain/budgetTree.ts";
import { createPostForPurchase } from "../../store/actions.ts";
import type { MonthId, Recurrence } from "../../domain/types.ts";
import type { MonthPostRow } from "../../domain/views.ts";
import { useDataset } from "../hooks/useDataset.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import { useMutate } from "../hooks/useMutate.ts";
import { DesktopPostBody, MobilePostRow } from "./PostTable.tsx";
import { PurchaseDialog } from "./PurchaseDialog.tsx";
function cadence(rule: Recurrence) {
const unit = rule.kind === "everyNMonths" ? "month" : rule.kind === "everyNWeeks" ? "week" : "day";
return `Every ${rule.n} ${unit}${rule.n === 1 ? "" : "s"}`;
}
/** A mixed placement tree; only post rows carry envelope balances. */
export function BudgetTree({ monthId, rows, onChangeRule }: { monthId: MonthId; rows: MonthPostRow[]; onChangeRule?: (postId: string) => void }) {
const dataset = useDataset();
const fmt = useMoneyFormat();
const { mutate, error } = useMutate();
const tree = budgetTree(dataset, monthId, rows);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const toggle = (id: string) => setExpanded(previous => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; });
const folderHeading = (node: Extract<BudgetNode, { kind: "folder" }>) => {
const open = expanded.has(node.folder.id);
const Icon = open ? ChevronDown : ChevronRight;
return <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
<button type="button" aria-label={node.folder.name} aria-expanded={open} onClick={() => toggle(node.folder.id)} className="flex items-center gap-2 rounded-sm py-2 font-medium hover:text-budget-accent focus-visible:outline-2 focus-visible:outline-ring"><Icon size={16} aria-hidden="true" />{node.folder.name}</button>
<span className="text-xs text-budget-ink-muted">Bills due <span className="font-money ml-2">{fmt.money(node.billsDue, dataset.settings.baseCurrency)}</span></span>
</div>;
};
const billContent = (node: Extract<BudgetNode, { kind: "bill" }>) => <div className="space-y-1 py-3 text-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2"><span className="font-medium">{node.cost.name} <span className="ml-2 font-money font-normal">{fmt.money(node.cost.amount.amount, node.cost.amount.currency)}</span></span><Link className="text-xs underline underline-offset-2 hover:text-budget-accent" to={`/settings#bill-${node.cost.id}`}>Edit</Link></div>
<p className="text-xs text-budget-ink-muted">{cadence(node.cost.recurrence)} · Assigned to {node.cost.splits.map(split => dataset.posts.find(post => post.id === split.postId)?.name).join(", ")} · {node.nextOccurrence ? `Next ${node.nextOccurrence.date}` : "Ended"}</p>
</div>;
const purchaseContent = (node: Extract<BudgetNode, { kind: "purchase" }>) => <div className="flex flex-wrap items-center justify-between gap-2 py-3 text-sm"><span>{node.purchase.description} <span className="ml-2 font-money">{fmt.money(node.amount.amount, node.amount.currency)}</span></span><span className="flex items-center gap-3">{node.purchase.splits.length === 0 && <button type="button" aria-label={`Create post for ${node.purchase.description}`} className="text-xs underline underline-offset-2 hover:text-budget-accent" onClick={() => void mutate(draft => createPostForPurchase(draft, node.purchase.id))}>Create post</button>}<PurchaseDialog monthId={monthId} purchase={node.purchase} trigger={<button type="button" className="text-xs underline underline-offset-2 hover:text-budget-accent">Edit</button>} /></span></div>;
const render = (nodes: BudgetNode[], mobile: boolean, depth = 0): ReactNode[] => nodes.flatMap(node => {
const indent = Math.min(depth, 4) * 16;
const id = node.kind === "folder" ? node.folder.id : node.kind === "post" ? node.row.post.id : node.kind === "purchase" ? node.purchase.id : node.cost.id;
if (node.kind === "post") {
const rowProps = { row: node.row, indent, monthId, onChangeRule, ...(node.children.length ? { childrenExpanded: expanded.has(id), onToggleChildren: () => toggle(id) } : {}) };
return [mobile ? <MobilePostRow key={id} {...rowProps} /> : <DesktopPostBody key={id} {...rowProps} />, ...(expanded.has(id) ? render(node.children, mobile, depth + 1) : [])];
}
const name = node.kind === "folder" ? node.folder.name : node.kind === "purchase" ? node.purchase.description : node.cost.name;
const content = node.kind === "folder" ? folderHeading(node) : node.kind === "purchase" ? purchaseContent(node) : billContent(node);
const style = { paddingInlineStart: `${indent}px` };
const item = mobile ? <li key={id} data-budget-item={id} data-budget-name={name} style={style}>{content}</li> : <tbody key={id} data-budget-item={id} data-budget-name={name}><tr><td colSpan={6} className={node.kind === "folder" ? "border-b border-budget-rule pt-3" : "border-b border-budget-rule"} style={style}>{content}</td></tr></tbody>;
return [item, ...(node.kind === "folder" && expanded.has(id) ? render(node.children, mobile, depth + 1) : [])];
});
return <>
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not save: {error}</p>}
<div className="hidden overflow-x-auto sm:block"><table className="w-full text-sm"><thead className="text-left"><tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">{["Budget item", "Carried in", "Allocated", "Spent", "Remaining", "Projected"].map((label, index) => <th key={label} className={`py-2 font-medium ${index ? "pl-6 text-right" : ""}`}>{label}</th>)}</tr></thead>
{render(tree.roots, false)}
{tree.unfiled.length > 0 && <tbody><tr><th colSpan={6} className="border-b border-budget-rule pb-2 pt-5 text-left font-medium">Unfiled</th></tr></tbody>}{render(tree.unfiled, false)}
<tbody><tr><th colSpan={6} className="border-b border-budget-rule pb-2 pt-5 text-left font-medium">Unassigned</th></tr></tbody>
{render(tree.unassigned, false, 1)}
{tree.unassigned.length === 0 && <tbody><tr><td colSpan={6} className="py-3 text-sm text-budget-ink-muted">No unassigned purchases this month.</td></tr></tbody>}
</table></div>
<ul className="space-y-3 sm:hidden">{render(tree.roots, true)}{tree.unfiled.length > 0 && <li className="border-b border-budget-rule pb-2 pt-4 font-medium">Unfiled</li>}{render(tree.unfiled, true)}<li className="border-b border-budget-rule pb-2 pt-4 font-medium">Unassigned</li>{render(tree.unassigned, true, 1)}{tree.unassigned.length === 0 && <li className="py-2 text-sm text-budget-ink-muted">No unassigned purchases this month.</li>}</ul>
</>;
}
+1
View File
@@ -68,6 +68,7 @@ function cost(overrides: Partial<RecurringCost> = {}): RecurringCost {
id: "r1",
name: "Rent",
order: 0,
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
+5 -3
View File
@@ -194,7 +194,7 @@ export function expectedGroups(
*/
export function ExpectedBand({ monthId }: { monthId: MonthId }) {
const dataset = useDataset();
const { mutate } = useMutate();
const { mutate, error } = useMutate();
const fmt = useMoneyFormat();
const [amounts, setAmounts] = useState<Record<string, string>>({});
@@ -262,7 +262,7 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
size="sm"
variant="outline"
disabled={invalid}
onClick={() => {
onClick={async () => {
// Captured before mutate, per AGENTS.md: mutate defers behind
// the write queue and React resets the input first, so reading
// in the callback commits stale text. `invalid` already guards
@@ -270,12 +270,13 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
// `parsed` is either a real amount or the field was never
// touched.
if (invalid) return;
mutate((draft) =>
const saved = await mutate((draft) =>
confirmOccurrence(draft, occurrence.recurringId, occurrence.date, {
date: recordedDate ?? undefined,
amount: parsed ?? undefined,
}),
);
if (!saved) return;
setAmounts((current) => {
const next = { ...current };
delete next[key];
@@ -320,6 +321,7 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
title="Expected"
hint="Committed but not yet spent. These do not touch a post's balance until you confirm one, which records it as an ordinary purchase you can edit or delete afterwards."
>
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not confirm: {error}</p>}
{pending.length > 0 && (
<ul className="divide-y divide-budget-rule">
{pending.map((occurrence) => row(occurrence, recordedDateFor("pending", today)))}
@@ -0,0 +1,94 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, expect, test } from "bun:test";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { createSnapshotStore } from "../../store/snapshot.ts";
import { store } from "../../store/index.ts";
import { HistoryControls } from "./HistoryControls.tsx";
const original = { getHistory: store.getHistory, subscribe: store.subscribe, undo: store.undo, redo: store.redo };
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) act(() => root!.unmount());
root = null;
container?.remove();
container = null;
Object.assign(store, original);
});
async function setup() {
let saved = createSeedDataset("2026-09");
let fail = false;
const history = createSnapshotStore({
async read() { return saved; },
async write(next) {
if (fail) throw new Error("Disk full");
saved = next;
},
}, "2026-09");
await history.load();
Object.assign(store, {
getHistory: history.getHistory,
subscribe: history.subscribe,
undo: history.undo,
redo: history.redo,
});
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root!.render(<HistoryControls />));
return { history, failWrites(value: boolean) { fail = value; } };
}
test("buttons follow saved history and restore data", async () => {
const { history } = await setup();
const undo = container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!;
const redo = container!.querySelector<HTMLButtonElement>('button[aria-label="Redo"]')!;
expect(undo.disabled).toBe(true);
expect(redo.disabled).toBe(true);
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
expect(undo.disabled).toBe(false);
await act(async () => { undo.click(); });
expect(history.get().posts[0]!.name).toBe("Video Games");
expect(redo.disabled).toBe(false);
await act(async () => { redo.click(); });
expect(history.get().posts[0]!.name).toBe("Changed");
});
test("a failed undo shows an error and permits retry", async () => {
const { history, failWrites } = await setup();
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
failWrites(true);
await act(async () => { container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!.click(); });
expect(container!.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
expect(history.get().posts[0]!.name).toBe("Changed");
failWrites(false);
await act(async () => { container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!.click(); });
expect(container!.querySelector('[role="alert"]')).toBeNull();
});
test("keyboard shortcut reverses saved edits outside editable controls", async () => {
const { history } = await setup();
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
const field = document.createElement("input");
container!.append(field);
await act(async () => {
field.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true }));
});
expect(history.get().posts[0]!.name).toBe("Changed");
await act(async () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true }));
});
expect(history.get().posts[0]!.name).toBe("Video Games");
await act(async () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true }));
});
expect(history.get().posts[0]!.name).toBe("Changed");
});
+59
View File
@@ -0,0 +1,59 @@
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { Redo2, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { store } from "../../store/index.ts";
/** Session history controls. Dataset writes remain serialized by the store. */
export function HistoryControls() {
const { canUndo, canRedo } = useSyncExternalStore(store.subscribe, store.getHistory);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const busyRef = useRef(false);
const run = useCallback(async (direction: "undo" | "redo") => {
if (busyRef.current) return;
busyRef.current = true;
setBusy(true);
try {
await store[direction]();
setError(null);
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
setError(`Could not ${direction}: ${message}. Your data has not changed.`);
} finally {
busyRef.current = false;
setBusy(false);
}
}, []);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.altKey || !(event.ctrlKey || event.metaKey)) return;
const target = event.target;
if (target instanceof Element && target.closest("input, textarea, select, [contenteditable]")) return;
const key = event.key.toLowerCase();
const direction = key === "z"
? (event.shiftKey ? "redo" : "undo")
: key === "y" && !event.shiftKey ? "redo" : null;
if (!direction) return;
event.preventDefault();
void run(direction);
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [run]);
return (
<div className="flex items-center gap-0.5 border-r border-budget-rule pr-1.5 mr-1.5">
<Button type="button" variant="ghost" size="icon-sm" aria-label="Undo"
title="Undo (Ctrl/Cmd+Z)" disabled={busy || !canUndo} onClick={() => void run("undo")}>
<Undo2 aria-hidden="true" />
</Button>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Redo"
title="Redo (Ctrl/Cmd+Shift+Z)" disabled={busy || !canRedo} onClick={() => void run("redo")}>
<Redo2 aria-hidden="true" />
</Button>
{error && <p role="alert" className="absolute right-4 top-full max-w-[calc(100vw-2rem)] rounded-b-md border border-t-0 border-destructive/40 bg-background px-3 py-2 text-sm text-destructive shadow-sm sm:right-6">{error}</p>}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { store } from "../../store/index.ts";
import { emptyDraft, type PurchaseDraft } from "../purchaseForm.ts";
import { PlanEditor } from "./PlanEditor.tsx";
test("a purchase dated by month can start a finance plan in that month", () => {
const original = { get: store.get, subscribe: store.subscribe };
const dataset = createSeedDataset("2026-09");
store.get = () => dataset;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
let draft: PurchaseDraft = { ...emptyDraft("2026-09", "food"), amount: 90 };
const render = () => root.render(<PlanEditor draft={draft} onChange={(next) => { draft = next; render(); }} />);
try {
act(render);
const button = container.querySelector("button")!;
expect(button.disabled).toBe(false);
act(() => button.click());
expect(draft.plan?.slices.map((slice) => slice.month)).toEqual(["2026-09", "2026-10", "2026-11"]);
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
});
test("an incomplete purchase date cannot start a finance plan", () => {
const original = { get: store.get, subscribe: store.subscribe };
const dataset = createSeedDataset("2026-09");
store.get = () => dataset;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
try {
act(() => root.render(<PlanEditor draft={{ ...emptyDraft("2026-09", "food"), date: "" }} onChange={() => {}} />));
expect(container.querySelector("button")!.disabled).toBe(true);
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
});
+7 -9
View File
@@ -17,15 +17,13 @@ interface Props {
onChange: (next: PurchaseDraft) => void;
}
// A native <input type="date"> reports "" until the user has filled in every
// field, and monthOf() throws on anything that isn't a well-formed
// YYYY-MM-DD. This is UI-level input validation, not a domain concern — the
// domain is right to throw on malformed input; the caller just must not call
// it with malformed input.
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function hasValidDate(draft: PurchaseDraft): boolean {
return ISO_DATE_PATTERN.test(draft.date);
try {
monthOf(draft.date);
return true;
} catch {
return false;
}
}
export function PlanEditor({ draft, onChange }: Props) {
@@ -53,7 +51,7 @@ export function PlanEditor({ draft, onChange }: Props) {
</Button>
{!dateReady && (
<p className="text-xs text-muted-foreground">
Pick a date for this purchase first.
Pick a month or date for this purchase first.
</p>
)}
</div>
+43
View File
@@ -0,0 +1,43 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router";
import { createSeedDataset } from "../../domain/seed.ts";
import { monthView } from "../../domain/views.ts";
import { store } from "../../store/index.ts";
import { PostTable } from "./PostTable.tsx";
test("an unbudgeted post shows no allocation or overspend status, unlike an explicit zero rule", () => {
const data = createSeedDataset("2026-09");
const [unbudgeted, zero] = data.posts;
unbudgeted!.name = "Unbudgeted";
unbudgeted!.rules = [];
zero!.name = "Zero rule";
zero!.rules = [{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 0, currency: "DKK" } } }];
data.purchases = [unbudgeted!, zero!].map((post, i) => ({
id: `p${i}`, date: "2026-09", description: post.name,
total: { amount: 25, currency: "DKK" }, splitMode: "percent" as const,
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], schedule: null,
}));
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
try {
act(() => root.render(<MemoryRouter><PostTable monthId="2026-09" rows={monthView(data, "2026-09").rows} /></MemoryRouter>));
const noneRow = container.querySelector('tbody[data-budget-name="Unbudgeted"]')!;
const zeroRow = container.querySelector('tbody[data-budget-name="Zero rule"]')!;
expect(noneRow.querySelectorAll("td")[2]!.textContent).toBe("—");
expect(noneRow.querySelectorAll("td")[4]!.classList.contains("text-overspend")).toBe(false);
expect(zeroRow.querySelectorAll("td")[2]!.textContent).toContain("0");
expect(zeroRow.querySelectorAll("td")[4]!.classList.contains("text-overspend")).toBe(true);
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
});
+44 -23
View File
@@ -1,4 +1,5 @@
import { Link } from "react-router";
import { ChevronDown, ChevronRight } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useMoneyFormat, type MoneyFormat } from "../hooks/useMoneyFormat.ts";
import { ruleAt } from "../../domain/allocation.ts";
@@ -50,21 +51,21 @@ interface RowCells {
* plain function, not a component, and both table markups call it from inside
* a map.
*/
function postRowCells({ figures }: MonthPostRow, fmt: MoneyFormat): RowCells {
function postRowCells({ figures, hasAllocationRule }: MonthPostRow, fmt: MoneyFormat): RowCells {
return {
carriedIn: fmt.signedAmount(figures.carriedIn),
carriedInTone:
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
: hasAllocationRule && figures.carriedIn < 0
? "text-overspend"
: "",
allocation: fmt.amount(figures.allocation),
allocation: hasAllocationRule ? fmt.amount(figures.allocation) : "—",
charges: fmt.amount(figures.charges),
remaining: fmt.signedAmount(figures.remaining),
remainingTone: figures.remaining < 0 ? "text-overspend" : "",
remainingTone: hasAllocationRule && figures.remaining < 0 ? "text-overspend" : "",
projected: fmt.signedAmount(figures.projected),
segments: carryMeterSegments(figures),
segments: carryMeterSegments(figures, hasAllocationRule),
};
}
@@ -76,7 +77,7 @@ export function PostTableLegend({ baseCurrency }: { baseCurrency: Currency }) {
return (
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span>
All figures in {baseCurrency}. Projected subtracts bills not yet confirmed.
Post figures in {baseCurrency}. Bill prices show their own currency. Projected subtracts bills not yet confirmed.
</span>
<Swatch token="--surplus" label="carried in" />
<Swatch token="--budget-accent" label="spent" />
@@ -173,7 +174,6 @@ function ChangeRuleButton({
}
export function PostTable({ monthId, rows, onChangeRule }: Props) {
const fmt = useMoneyFormat();
return (
<>
<div className="hidden overflow-x-auto sm:block">
@@ -194,11 +194,38 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
belong together, which is exactly how the meter came to look like
a divider between two rows rather than part of one. */}
{rows.map((row) => {
const cells = postRowCells(row, fmt);
return (
<tbody key={row.post.id} className="group">
<DesktopPostBody key={row.post.id} row={row} monthId={monthId} onChangeRule={onChangeRule} />
);
})}
</table>
</div>
<ul className="space-y-4 sm:hidden">
{rows.map((row) => {
return (
<MobilePostRow key={row.post.id} row={row} monthId={monthId} onChangeRule={onChangeRule} />
);
})}
</ul>
</>
);
}
interface PostRowProps { row: MonthPostRow; monthId: MonthId; onChangeRule?: (postId: string) => void; indent?: number; childrenExpanded?: boolean; onToggleChildren?: () => void; }
function PostChildrenToggle({ postName, expanded, onToggle }: { postName: string; expanded: boolean; onToggle: () => void }) {
const Icon = expanded ? ChevronDown : ChevronRight;
return <button type="button" aria-label={`Bills under ${postName}`} aria-expanded={expanded} onClick={onToggle} className="mr-1 inline-flex size-6 align-middle items-center justify-center rounded-sm text-budget-ink-muted hover:text-budget-accent focus-visible:outline-2 focus-visible:outline-ring"><Icon size={16} aria-hidden="true" /></button>;
}
export function DesktopPostBody({ row, monthId, onChangeRule, indent = 0, childrenExpanded, onToggleChildren }: PostRowProps) {
const cells = postRowCells(row, useMoneyFormat());
return (
<tbody data-budget-item={row.post.id} data-budget-name={row.post.name} className="group">
<tr className="transition-colors group-hover:bg-accent/60">
<td className="pb-0 pt-3">
<td className="pb-0 pt-3" style={{ paddingInlineStart: indent }}>
{onToggleChildren && <PostChildrenToggle postName={row.post.name} expanded={!!childrenExpanded} onToggle={onToggleChildren} />}
<PostName row={row} monthId={monthId} />
{onChangeRule && (
<ChangeRuleButton
@@ -234,18 +261,16 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
</td>
</tr>
</tbody>
);
})}
</table>
</div>
);
}
<ul className="space-y-4 sm:hidden">
{rows.map((row) => {
const cells = postRowCells(row, fmt);
return (
<li key={row.post.id} className="space-y-1.5">
export function MobilePostRow({ row, monthId, onChangeRule, indent = 0, childrenExpanded, onToggleChildren }: PostRowProps) {
const cells = postRowCells(row, useMoneyFormat());
return (
<li data-budget-item={row.post.id} data-budget-name={row.post.name} className="space-y-1.5" style={{ paddingInlineStart: indent }}>
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="min-w-0">
{onToggleChildren && <PostChildrenToggle postName={row.post.name} expanded={!!childrenExpanded} onToggle={onToggleChildren} />}
<PostName row={row} monthId={monthId} />
</span>
<span
@@ -281,9 +306,5 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
)}
</p>
</li>
);
})}
</ul>
</>
);
}
+25 -2
View File
@@ -35,6 +35,8 @@ import {
addPurchase,
addRecurringCost,
cancelScheduleFrom,
placePurchase,
placeRecurringUnderPost,
updatePurchase,
} from "../../store/actions.ts";
import {
@@ -62,8 +64,12 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
const activePosts = dataset.posts
.filter((p) => !p.archived)
.sort((a, b) => a.order - b.order);
const editablePosts = dataset.posts
.filter((p) => !p.archived || purchase?.splits.some(split => split.postId === p.id))
.sort((a, b) => a.order - b.order);
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState(() => purchase?.placement ? purchase.placement.folderId ?? "__unfiled" : "__ledger");
const [draft, setDraft] = useState<PurchaseDraft>(() =>
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
);
@@ -107,6 +113,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
: offerable;
function reset() {
setPlacement(purchase?.placement ? purchase.placement.folderId ?? "__unfiled" : "__ledger");
setDraft(
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
);
@@ -142,6 +149,9 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
data,
seedCostFromDraft(draft, newRecurrence, newAnchoring),
);
// Nested under its post when it has exactly one, the same default a
// bill created in Settings gets.
if (seeded.splits.length === 1) placeRecurringUnderPost(data, seeded.id, seeded.splits[0]!.postId);
source = sourceFor(data, seeded, draft.date);
} else if (recurringSelection !== "none") {
const cost = data.recurring.find((c) => c.id === recurringSelection);
@@ -165,6 +175,8 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
// when the owner picks "None" would leave a stored association
// untouched instead of clearing it.
updatePurchase(data, purchase.id, { ...toPurchase(draft), source });
const destination = draft.splits.length === 0 || placement === "__ledger" ? undefined : placement === "__unfiled" ? null : placement;
if (destination !== purchase.placement?.folderId || (destination === undefined) !== (purchase.placement === undefined)) placePurchase(data, purchase.id, destination);
} else {
addPurchase(data, { ...toPurchase(draft), ...(source ? { source } : {}) });
}
@@ -246,7 +258,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
setBulk((b) => ({ ...b, postId }));
}}
>
<option value="">Choose a post…</option>
<option value="">No post (unassigned)</option>
{activePosts.map((post) => (
<option key={post.id} value={post.id}>
{post.name}
@@ -557,7 +569,18 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
</fieldset>
)}
<SplitEditor draft={draft} posts={activePosts} onChange={setDraft} />
<SplitEditor draft={draft} posts={purchase ? editablePosts : activePosts} onChange={setDraft} />
{purchase && draft.splits.length === 0 && <p className="text-xs text-budget-ink-muted">This purchase appears under Unassigned in Budget.</p>}
{purchase && draft.splits.length > 0 && <div className="space-y-1">
<Label htmlFor="purchase-placement">Budget listing</Label>
<NativeSelect id="purchase-placement" value={placement} onChange={event => setPlacement(event.target.value)}>
<option value="__ledger">Monthly purchases only</option>
<option value="__unfiled">Own row in Unfiled</option>
{dataset.folders.map(folder => <option key={folder.id} value={folder.id}>Own row in {folder.name}</option>)}
</NativeSelect>
<p className="text-xs text-budget-ink-muted">Post splits above determine which balances this purchase charges.</p>
</div>}
<PlanEditor draft={draft} onChange={setDraft} />
</>
@@ -0,0 +1,30 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { RecurringSplitEditor, type RecurringSplitSelection } from "./RecurringSplitEditor.tsx";
test("bill can split across active posts", () => {
const data = createSeedDataset("2026-09");
data.posts[0]!.archived = true;
const active = data.posts.filter(post => !post.archived);
let selection: RecurringSplitSelection = { splitMode: "percent", splits: [{ postId: active[0]!.id, value: 100, absorbsRemainder: true }] };
const container = document.createElement("div");
const root = createRoot(container);
const render = () => root.render(<RecurringSplitEditor {...selection} posts={active} onChange={next => { selection = next; render(); }} />);
act(render);
expect(container.querySelector(`option[value="${data.posts[0]!.id}"]`)).toBeNull();
act(() => { (Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Add another post"))!).click(); });
expect(selection.splits).toHaveLength(2);
const selects = container.querySelectorAll("select");
act(() => { selects[2]!.value = active[1]!.id; selects[2]!.dispatchEvent(new Event("change", { bubbles: true })); });
const radios = container.querySelectorAll('input[type="radio"]');
act(() => { (radios[1] as HTMLInputElement).click(); });
expect(selection.splits.map(split => split.postId)).toEqual([active[0]!.id, active[1]!.id]);
expect(selection.splits.map(split => split.absorbsRemainder)).toEqual([false, true]);
act(() => root.unmount());
});
@@ -0,0 +1,60 @@
import { Button } from "@/components/ui/button";
import { useId } from "react";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import type { Post, RecurringCost, Split } from "../../domain/types.ts";
export type RecurringSplitSelection = Pick<RecurringCost, "splitMode" | "splits">;
interface Props extends RecurringSplitSelection {
posts: Post[];
onChange: (next: RecurringSplitSelection) => void;
}
export function RecurringSplitEditor({ splitMode, splits, posts, onChange }: Props) {
const absorberGroup = useId();
function changeSplit(index: number, changes: Partial<Split>) {
onChange({ splitMode, splits: splits.map((split, i) => i === index ? { ...split, ...changes } : split) });
}
return (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Destination posts</legend>
{splits.length > 1 && (
<NativeSelect aria-label="Split by" value={splitMode} onChange={event => {
const mode = event.target.value as RecurringCost["splitMode"];
onChange({ splitMode: mode, splits: splits.map((split, index) => ({ ...split, value: mode === "percent" && index === 0 ? 100 : 0 })) });
}}>
<option value="percent">Percentages</option>
<option value="fixed">Amounts</option>
</NativeSelect>
)}
{splits.map((split, index) => (
<div key={index} className="flex flex-wrap items-center gap-2">
<NativeSelect
aria-label={index === 0 ? "Destination post" : `Destination post ${index + 1}`}
className="min-w-36 flex-1"
value={split.postId}
onChange={event => changeSplit(index, { postId: event.target.value })}
>
<option value="">Choose a post…</option>
{posts.map(post => <option key={post.id} value={post.id}>{post.name}</option>)}
</NativeSelect>
{splits.length > 1 && (
<>
<Input aria-label={`Split value ${index + 1}`} type="number" step="0.01" className="w-24" value={split.value} onChange={event => changeSplit(index, { value: Number(event.target.value) || 0 })} />
<span className="text-xs text-budget-ink-muted">{splitMode === "percent" ? "%" : "amount"}</span>
<label className="flex items-center gap-1 text-xs"><input type="radio" name={absorberGroup} checked={split.absorbsRemainder} onChange={() => onChange({ splitMode, splits: splits.map((item, i) => ({ ...item, absorbsRemainder: i === index })) })} />rounding</label>
<Button type="button" size="xs" variant="ghost" onClick={() => {
const remaining = splits.filter((_, i) => i !== index);
if (!remaining.some(item => item.absorbsRemainder)) remaining[0] = { ...remaining[0]!, absorbsRemainder: true };
onChange({ splitMode, splits: remaining });
}}>remove</Button>
</>
)}
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={() => onChange({ splitMode, splits: [...splits, { postId: "", value: 0, absorbsRemainder: false }] })}>Add another post</Button>
</fieldset>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, expect, test } from "bun:test";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { store } from "../../store/index.ts";
import { RuleHistory } from "./RuleHistory.tsx";
import { RuleFromMonth } from "../routes/MonthRoute.tsx";
const originalMutate = store.mutate;
let root: Root | null = null;
let container: HTMLDivElement | null = null;
function mount(child: React.ReactNode): HTMLDivElement {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root!.render(child));
return container;
}
afterEach(() => {
store.mutate = originalMutate;
if (root) act(() => root!.unmount());
root = null;
container?.remove();
container = null;
});
test("change from here preserves the currency of a prefilled fixed rule", async () => {
const data = createSeedDataset("2026-01");
const post = data.posts[0]!;
const initial = { kind: "fixed" as const, amount: { amount: 100, currency: "USD" } };
let saved = false;
store.mutate = async (change) => {
change(data);
saved = true;
};
const el = mount(
<RuleFromMonth post={post} monthId="2026-01" initial={initial} onDone={() => {}} />,
);
expect(el.querySelector('input[aria-label="Amount in USD"]')).not.toBeNull();
await act(async () => {
(Array.from(el.querySelectorAll("button")).find((button) => button.textContent === "Apply")!).click();
});
expect(saved).toBe(true);
expect(post.rules[0]!.rule).toEqual(initial);
});
test("change from here keeps the editor open and explains a failed write", async () => {
const data = createSeedDataset("2026-01");
let closed = false;
store.mutate = async () => {
throw new Error("Disk full");
};
const el = mount(
<RuleFromMonth
post={data.posts[0]!}
monthId="2026-01"
initial={{ kind: "percentOfIncome", percent: 10 }}
onDone={() => { closed = true; }}
/>,
);
await act(async () => {
(Array.from(el.querySelectorAll("button")).find((button) => button.textContent === "Apply")!).click();
});
expect(closed).toBe(false);
expect(el.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
});
test("rule history explains a failed write", async () => {
const data = createSeedDataset("2026-01");
const post = data.posts[0]!;
post.rules = [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 10 } }];
store.mutate = async () => {
throw new Error("Disk full");
};
const el = mount(<RuleHistory post={post} />);
await act(async () => {
(Array.from(el.querySelectorAll("button")).find((button) => button.textContent === "remove")!).click();
});
expect(el.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
});
test("rule history shows imported versions in chronological order", () => {
const data = createSeedDataset("2026-01");
const post = data.posts[0]!;
post.rules = [
{ from: "2026-07", rule: { kind: "percentOfIncome", percent: 15 } },
{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 10 } },
];
const el = mount(<RuleHistory post={post} />);
const months = Array.from(el.querySelectorAll("tbody tr td:first-child"), (cell) => cell.textContent);
expect(months).toEqual(["2026-01", "2026-07"]);
});
+12 -8
View File
@@ -27,12 +27,13 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/;
* so rather than letting it look like an append.
*/
export function RuleHistory({ post }: Props) {
const { mutate } = useMutate();
const { mutate, error, clearError } = useMutate();
const [from, setFrom] = useState<string>(currentMonth);
const [kind, setKind] = useState<Rule["kind"]>("percentOfIncome");
// Held as text, not a number, so the field can be cleared and can hold a
// half-typed "12." mid-entry. Parsed once, on submit.
const [value, setValue] = useState<string>("");
const versions = [...post.rules].sort((a, b) => a.from.localeCompare(b.from));
const replaces = post.rules.some((v) => v.from === from);
const monthOk = MONTH_ID.test(from);
@@ -44,15 +45,16 @@ export function RuleHistory({ post }: Props) {
kind === "fixed"
? { kind: "fixed", amount: { amount, currency: post.currency } }
: { kind: "percentOfIncome", percent: amount };
mutate((draft) => setRuleFrom(draft, post.id, from, rule));
clearError();
void mutate((draft) => setRuleFrom(draft, post.id, from, rule));
}
return (
<div className="space-y-3 rounded-md border border-budget-rule p-3">
{post.rules.length === 0 ? (
<p className="text-xs text-muted-foreground">
Not budgeted. Until a rule starts, this post is allocated nothing —
spending still records, and shows as overspend.
Not budgeted. Until a rule starts, this post is allocated nothing.
Spending still records without an overspend status.
</p>
) : (
<table className="w-full text-xs">
@@ -64,7 +66,7 @@ export function RuleHistory({ post }: Props) {
</tr>
</thead>
<tbody>
{post.rules.map((version) => (
{versions.map((version) => (
<tr key={version.from} className="border-t">
<td className="py-1 font-money">{version.from}</td>
<td className="py-1">
@@ -76,9 +78,10 @@ export function RuleHistory({ post }: Props) {
<Button
size="sm"
variant="ghost"
onClick={() =>
mutate((draft) => removeRuleFrom(draft, post.id, version.from))
}
onClick={() => {
clearError();
void mutate((draft) => removeRuleFrom(draft, post.id, version.from));
}}
>
remove
</Button>
@@ -137,6 +140,7 @@ export function RuleHistory({ post }: Props) {
</span>
)}
</div>
{error && <p role="alert" className="text-sm text-destructive">Could not save: {error}</p>}
</div>
);
}
+22
View File
@@ -39,6 +39,26 @@ export function SplitEditor<T extends SplitTarget>({ draft, posts, onChange }: P
return (
<fieldset className="space-y-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={draft.splits.length === 0}
onChange={(event) =>
onChange({
...draft,
splits: event.target.checked
? []
: posts[0]
? [{ postId: posts[0].id, value: 100, absorbsRemainder: true }]
: [],
})
}
disabled={posts.length === 0 && draft.splits.length === 0}
/>
No post — leave this purchase unassigned
</label>
{draft.splits.length > 0 && (
<>
<div className="flex items-center justify-between">
<Label>Split across posts</Label>
<Segmented
@@ -136,6 +156,8 @@ export function SplitEditor<T extends SplitTarget>({ draft, posts, onChange }: P
: `${fmt.amount(balance)} ${unit} unassigned — the rounding post absorbs it.`}
</span>
</div>
</>
)}
</fieldset>
);
}
+11 -2
View File
@@ -59,7 +59,7 @@ export function YearMatrix({ view, mode }: Props) {
<td
key={i}
className={`font-money py-2 pl-4 text-right whitespace-nowrap ${
mode === "balance" && value < 0 ? "text-overspend" : ""
mode === "balance" && row.hasAllocationRuleByMonth[i] && value < 0 ? "text-overspend" : ""
}`}
>
{fmt.amount(value)}
@@ -68,13 +68,22 @@ export function YearMatrix({ view, mode }: Props) {
})}
<td
className={`font-money py-2 pl-4 text-right font-medium whitespace-nowrap ${
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
mode === "balance" && row.hasAllocationRuleByMonth[11] && row.closingBalance < 0 ? "text-overspend" : ""
}`}
>
{fmt.amount(mode === "spend" ? row.totalCharges : row.closingBalance)}
</td>
</tr>
))}
{mode === "spend" && view.totalLooseCharges !== 0 && (
<tr className="border-b border-budget-rule text-budget-ink-muted">
<td className="py-2">Unassigned</td>
{view.looseByMonth.map((amount, i) => (
<td key={i} className="font-money py-2 pl-4 text-right whitespace-nowrap">{fmt.amount(amount)}</td>
))}
<td className="font-money py-2 pl-4 text-right whitespace-nowrap">{fmt.amount(view.totalLooseCharges)}</td>
</tr>
)}
</tbody>
</table>
</div>
+4 -1
View File
@@ -18,7 +18,10 @@ export function useMutate() {
const mutate = useCallback((fn: (draft: Dataset) => void): Promise<boolean> => {
return store.mutate(fn).then(
() => true,
() => {
setError(null);
return true;
},
(cause: unknown) => {
setError(cause instanceof Error ? cause.message : String(cause));
return false;
+5
View File
@@ -69,6 +69,11 @@ test("overspending paints past the end of the allocation and leaves no unspent t
]);
});
test("spending without an allocation rule uses the spending color, not overspend", () => {
const segments = carryMeterSegments(figures({ charges: 250, remaining: -250 }), false);
expect(widths(segments)).toEqual([["--budget-accent", 100]]);
});
test("a negative carried-in balance contributes no surplus segment", () => {
// Transport in the demo data: behind by 1600, allocated 1100, spent 2700.
// The debt is not on the meter — only the red Carried-in figure says why the
+13 -2
View File
@@ -43,15 +43,26 @@ function scaled(parts: [string, number][], scale: number): MeterSegment[] {
* --overspend spend beyond the allocation
*
* Negatives are clamped rather than inverted. A carried-in *debt* therefore
* does not appear here at all — only the red Carried-in figure beside the
* does not appear here at all — only the Carried-in figure beside the
* meter says why the post is behind — and a net refund reads as no spend.
* Both are deliberate: a segment of negative width would put the gradient's
* stops out of order, which paints something arbitrary instead of failing.
* Without an allocation rule, spending uses the ordinary spend color: there
* is no budget threshold to paint as overspent.
*/
export function carryMeterSegments(figures: PostMonthFigures): MeterSegment[] {
export function carryMeterSegments(
figures: PostMonthFigures,
hasAllocationRule = true,
): MeterSegment[] {
const surplus = Math.max(figures.carriedIn, 0);
const allocation = Math.max(figures.allocation, 0);
const charges = Math.max(figures.charges, 0);
if (!hasAllocationRule) {
return scaled(
[["--surplus", surplus], ["--budget-accent", charges]],
surplus + charges,
);
}
const scale = surplus + Math.max(allocation, charges);
if (scale <= 0) return EMPTY_TRACK;
+4 -6
View File
@@ -56,6 +56,10 @@ describe("splitBalance", () => {
});
describe("validatePurchase", () => {
test("allows a purchase with no post", () => {
expect(validatePurchase(draft({ splits: [] }))).toEqual([]);
expect(toPurchase(draft({ splits: [] })).splits).toEqual([]);
});
test("a well-formed draft has no errors", () => {
expect(validatePurchase(draft())).toEqual([]);
});
@@ -72,12 +76,6 @@ describe("validatePurchase", () => {
);
});
test("requires at least one split", () => {
expect(validatePurchase(draft({ splits: [] }))).toContain(
"Add at least one post to split across.",
);
});
test("requires exactly one remainder absorber", () => {
const none = draft({
splits: [{ postId: "food", value: 100, absorbsRemainder: false }],
+2 -5
View File
@@ -52,7 +52,7 @@ export function emptyDraft(monthId: MonthId, postId: string): PurchaseDraft {
amount: 0,
currency: "DKK",
splitMode: "percent",
splits: [{ postId, value: 100, absorbsRemainder: true }],
splits: postId ? [{ postId, value: 100, absorbsRemainder: true }] : [],
plan: null,
};
}
@@ -73,10 +73,7 @@ export function validatePurchase(draft: PurchaseDraft): string[] {
if (draft.amount === 0) {
errors.push("Enter an amount other than zero.");
}
if (draft.splits.length === 0) {
errors.push("Add at least one post to split across.");
}
if (draft.splits.filter((s) => s.absorbsRemainder).length !== 1) {
if (draft.splits.length > 0 && draft.splits.filter((s) => s.absorbsRemainder).length !== 1) {
errors.push("Choose exactly one post to absorb rounding.");
}
const postIds = draft.splits.map((s) => s.postId);
+4 -4
View File
@@ -12,7 +12,7 @@ import type { Dataset } from "../domain/types.ts";
function withCosts(): Dataset {
const d = createSeedDataset("2026-09");
const post = d.posts[0]!;
actions.addRecurringCost(d, {
actions.addRecurringCost(d, { folderId: null,
name: "Rent",
archived: false,
amount: { amount: 8200, currency: "DKK" },
@@ -22,7 +22,7 @@ function withCosts(): Dataset {
splitMode: "percent",
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }],
});
actions.addRecurringCost(d, {
actions.addRecurringCost(d, { folderId: null,
name: "Phone",
archived: false,
amount: { amount: 300, currency: "DKK" },
@@ -54,7 +54,7 @@ describe("offerableCosts", () => {
test("a monthly anchored cost is not offered for a month-only date either", () => {
const d = withCosts();
const post = d.posts[0]!;
actions.addRecurringCost(d, {
actions.addRecurringCost(d, { folderId: null,
name: "Streaming",
archived: false,
amount: { amount: 129, currency: "DKK" },
@@ -136,7 +136,7 @@ describe("sourceFor", () => {
test("a day-granular calendar cost's source names the REAL slot date, not the bare month", () => {
const d = createSeedDataset("2026-09");
const post = d.posts[0]!;
const cost = actions.addRecurringCost(d, {
const cost = actions.addRecurringCost(d, { folderId: null,
name: "Groceries plan",
archived: false,
amount: { amount: 500, currency: "DKK" },
+3 -1
View File
@@ -163,10 +163,12 @@ export function seedCostFromDraft(
draft: PurchaseDraft,
recurrence: Recurrence,
anchoring: Anchoring,
): Omit<RecurringCost, "id" | "order"> {
): Omit<RecurringCost, "id" | "order" | "parentPostId"> {
const monthGranular = recurrence.kind === "everyNMonths" && anchoring === "calendar";
return {
name: draft.description,
// Unfiled; the dialog nests a single-post bill under its post afterwards.
folderId: null,
archived: false,
amount: { amount: draft.amount, currency: draft.currency },
startDate: monthGranular ? monthOf(draft.date) : draft.date,
+22 -9
View File
@@ -13,7 +13,8 @@ import { ruleAt } from "../../domain/allocation.ts";
import type { MonthId, Post, Purchase, Rule } from "../../domain/types.ts";
import { sliceAmountForMonth } from "../../domain/charges.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import { PostTable, PostTableLegend } from "../components/PostTable.tsx";
import { PostTableLegend } from "../components/PostTable.tsx";
import { BudgetTree } from "../components/BudgetTree.tsx";
import { PurchaseDialog } from "../components/PurchaseDialog.tsx";
import { ExpectedBand } from "../components/ExpectedBand.tsx";
import { Meter } from "../components/Meter.tsx";
@@ -111,6 +112,9 @@ export function MonthRoute() {
<div className="mt-4 flex flex-wrap items-end justify-between gap-x-10 gap-y-2 border-t border-budget-rule pt-3">
<dl>
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
{view.looseCharges !== 0 && (
<Stat label="Unassigned">{fmt.money(view.looseCharges, base)}</Stat>
)}
</dl>
{/* Rendered in both directions. "No posts overspent" is a real answer
to the app's second question, and a line that only appears when
@@ -128,11 +132,11 @@ export function MonthRoute() {
<ExpectedBand monthId={monthId} />
<Section
title="Posts"
title="Budget"
hint={<PostTableLegend baseCurrency={base} />}
className="overflow-hidden"
>
<PostTable monthId={monthId} rows={view.rows} onChangeRule={setChangingRuleFor} />
<BudgetTree monthId={monthId} rows={view.rows} onChangeRule={setChangingRuleFor} />
</Section>
{changingPost && (
@@ -225,6 +229,9 @@ function PurchaseRow({
financed
</span>
)}
{purchase.splits.length === 0 && (
<span className="shrink-0 text-xs text-budget-ink-muted">unassigned</span>
)}
<span className="font-money shrink-0 tabular-nums">
{fmt.money(slice.amount, slice.currency)}
</span>
@@ -300,7 +307,7 @@ function MonthStep({ to, label, back = false }: { to: string; label: string; bac
* same `setRuleFrom` action the Settings history editor uses, so the two
* surfaces cannot drift apart.
*/
function RuleFromMonth({
export function RuleFromMonth({
post,
monthId,
initial,
@@ -311,7 +318,7 @@ function RuleFromMonth({
initial: Rule | undefined;
onDone: () => void;
}) {
const { mutate } = useMutate();
const { mutate, error, clearError } = useMutate();
// Pre-filled with what is currently in effect, so nudging 10% to 15% is an
// edit rather than re-entry. Empty when the post has no rule yet. Held as
// text so the field can be cleared mid-edit; parsed once, on apply.
@@ -321,6 +328,9 @@ function RuleFromMonth({
? ""
: String(initial.kind === "fixed" ? initial.amount.amount : initial.percent),
);
const [currency] = useState(() =>
initial?.kind === "fixed" ? initial.amount.currency : post.currency,
);
const amount = Number(value);
const valueOk = value.trim() !== "" && Number.isFinite(amount);
@@ -328,10 +338,12 @@ function RuleFromMonth({
function apply() {
const rule: Rule =
kind === "fixed"
? { kind: "fixed", amount: { amount, currency: post.currency } }
? { kind: "fixed", amount: { amount, currency } }
: { kind: "percentOfIncome", percent: amount };
mutate((draft) => setRuleFrom(draft, post.id, monthId, rule));
onDone();
clearError();
void mutate((draft) => setRuleFrom(draft, post.id, monthId, rule)).then((saved) => {
if (saved) onDone();
});
}
return (
@@ -355,7 +367,7 @@ function RuleFromMonth({
className="font-money h-8 w-24"
type="number"
step="0.01"
aria-label={kind === "fixed" ? `Amount in ${post.currency}` : "Percent of income"}
aria-label={kind === "fixed" ? `Amount in ${currency}` : "Percent of income"}
value={value}
onChange={(event) => {
const next = event.target.value;
@@ -368,6 +380,7 @@ function RuleFromMonth({
<Button size="sm" variant="ghost" onClick={onDone}>
Cancel
</Button>
{error && <p role="alert" className="w-full text-sm text-destructive">Could not save: {error}</p>}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router";
import { createSeedDataset } from "../../domain/seed.ts";
import { yearView } from "../../domain/views.ts";
import { store } from "../../store/index.ts";
import { YearMatrix } from "../components/YearMatrix.tsx";
import { PostMonthRoute } from "./PostMonthRoute.tsx";
import { PostYearRoute } from "./PostYearRoute.tsx";
function withUnbudgetedPost(run: (container: HTMLDivElement, postId: string) => void) {
const data = createSeedDataset("2026-09");
const post = data.posts[0]!;
post.rules = [];
data.purchases.push({
id: "unbudgeted-purchase", date: "2026-09", description: "Unbudgeted spending",
total: { amount: 25, currency: "DKK" }, splitMode: "percent",
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], schedule: null,
});
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
try {
act(() => root.render(<MemoryRouter key="month" initialEntries={[`/post/${post.id}/month/2026-09`]}>
<Routes>
<Route path="/post/:postId/month/:monthId" element={<PostMonthRoute />} />
</Routes>
</MemoryRouter>));
run(container, post.id);
act(() => root.render(<MemoryRouter key="year" initialEntries={[`/post/${post.id}/year/2026`]}>
<Routes><Route path="/post/:postId/year/:year" element={<PostYearRoute />} /></Routes>
</MemoryRouter>));
run(container, post.id);
act(() => root.render(<MemoryRouter key="matrix"><YearMatrix view={yearView(data, 2026)} mode="balance" /></MemoryRouter>));
run(container, post.id);
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
}
test("post detail and year balances stay neutral when no allocation rule exists", () => {
let pass = 0;
withUnbudgetedPost((container) => {
pass++;
if (pass === 1) {
const details = container.querySelector("dl")!;
expect(details.querySelectorAll("dd")[1]!.textContent).toBe("—");
expect(details.querySelectorAll("dd")[3]!.classList.contains("text-overspend")).toBe(false);
} else if (pass === 2) {
expect(container.querySelector("p")!.textContent).toContain("Allocated —");
const september = [...container.querySelectorAll("tr")].find((row) => row.textContent?.includes("2026-09"))!;
expect(september.querySelectorAll("td")[2]!.textContent).toBe("—");
expect(september.querySelectorAll("td")[4]!.classList.contains("text-overspend")).toBe(false);
} else {
const row = container.querySelector("tbody tr:nth-child(2)")!;
expect(row.querySelectorAll("td")[9]!.classList.contains("text-overspend")).toBe(false);
expect(row.querySelectorAll("td")[13]!.classList.contains("text-overspend")).toBe(false);
}
});
});
+3 -3
View File
@@ -68,7 +68,7 @@ export function PostMonthRoute() {
className={`font-money ${
(row?.figures.carriedIn ?? 0) > 0
? "text-surplus"
: (row?.figures.carriedIn ?? 0) < 0
: row?.hasAllocationRule && (row?.figures.carriedIn ?? 0) < 0
? "text-overspend"
: ""
}`}
@@ -78,7 +78,7 @@ export function PostMonthRoute() {
</div>
<div>
<dt className="text-muted-foreground">Allocated</dt>
<dd className="font-money">{fmt.money(row?.figures.allocation ?? 0, base)}</dd>
<dd className="font-money">{row?.hasAllocationRule ? fmt.money(row.figures.allocation, base) : "—"}</dd>
</div>
<div>
<dt className="text-muted-foreground">Spent</dt>
@@ -87,7 +87,7 @@ export function PostMonthRoute() {
<div>
<dt className="text-muted-foreground">Remaining</dt>
<dd
className={`font-money ${(row?.figures.remaining ?? 0) < 0 ? "text-overspend" : ""}`}
className={`font-money ${row?.hasAllocationRule && (row.figures.remaining < 0) ? "text-overspend" : ""}`}
>
{fmt.signedMoney(row?.figures.remaining ?? 0, base)}
</dd>
+5 -4
View File
@@ -21,7 +21,7 @@ export function PostYearRoute() {
{post.name} &middot; {year}
</h1>
<p className="text-sm text-muted-foreground">
Allocated {fmt.money(row.totalAllocation, base)} &middot; spent{" "}
Allocated {row.hasAllocationRuleByMonth.some(Boolean) ? fmt.money(row.totalAllocation, base) : "—"} &middot; spent{" "}
{fmt.money(row.totalCharges, base)} &middot; closing balance{" "}
{fmt.signedMoney(row.closingBalance, base)}
</p>
@@ -40,6 +40,7 @@ export function PostYearRoute() {
<tbody>
{view.months.map((month, i) => {
const figures = row.byMonth[i]!;
const hasAllocationRule = row.hasAllocationRuleByMonth[i]!;
return (
<tr key={month} className="border-b last:border-0">
<td className="py-2">
@@ -51,18 +52,18 @@ export function PostYearRoute() {
className={`font-money py-2 text-right ${
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
: hasAllocationRule && figures.carriedIn < 0
? "text-overspend"
: ""
}`}
>
{fmt.signedMoney(figures.carriedIn, base)}
</td>
<td className="font-money py-2 text-right">{fmt.amount(figures.allocation)}</td>
<td className="font-money py-2 text-right">{hasAllocationRule ? fmt.amount(figures.allocation) : "—"}</td>
<td className="font-money py-2 text-right">{fmt.amount(figures.charges)}</td>
<td
className={`font-money py-2 text-right font-medium ${
figures.remaining < 0 ? "text-overspend" : ""
hasAllocationRule && figures.remaining < 0 ? "text-overspend" : ""
}`}
>
{fmt.signedMoney(figures.remaining, base)}
+51
View File
@@ -0,0 +1,51 @@
import { expect, test } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { act } from "react";
import { createRoot } from "react-dom/client";
import { Link, MemoryRouter, Route, Routes } from "react-router";
import { createSeedDataset } from "../../domain/seed.ts";
import { addRecurringCost } from "../../store/actions.ts";
import { store } from "../../store/index.ts";
import { monthView } from "../../domain/views.ts";
import { BudgetTree } from "../components/BudgetTree.tsx";
import { SettingsRoute } from "./SettingsRoute.tsx";
test("month bill Edit navigates to Settings, opens and focuses its editor; fragment changes retarget it", () => {
const data = createSeedDataset("2026-09");
const bill = addRecurringCost(data, {
name: "Phone", folderId: null, archived: false,
amount: { amount: 30, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
});
const second = addRecurringCost(data, { ...bill, name: "Internet" });
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
try {
act(() => root.render(<MemoryRouter initialEntries={["/month/2026-09"]}>
<Link id="second-bill" to={`/settings#bill-${second.id}`}>Other bill</Link>
<Link id="missing-bill" to="/settings#bill-missing">Missing bill</Link>
<Routes>
<Route path="/month/:month" element={<BudgetTree monthId="2026-09" rows={monthView(data, "2026-09").rows} />} />
<Route path="/settings" element={<SettingsRoute />} />
</Routes>
</MemoryRouter>));
act(() => (container.querySelector(`[data-budget-item="${bill.id}"] a`) as HTMLAnchorElement).click());
expect(container.textContent).toContain("Save destinations");
expect(document.activeElement?.id).toBe(`bill-${bill.id}`);
act(() => (container.querySelector("#second-bill") as HTMLAnchorElement).click());
expect(document.activeElement?.id).toBe(`bill-${second.id}`);
const editorRow = container.querySelector(`[data-bill-id="${second.id}"]`)!.nextElementSibling!;
expect(editorRow.textContent).toContain("Save destinations");
act(() => (container.querySelector("#missing-bill") as HTMLAnchorElement).click());
expect(container.textContent).toContain("Recurring costs");
} finally {
act(() => root.unmount()); container.remove(); Object.assign(store, original);
}
});
+16
View File
@@ -1,4 +1,7 @@
import { useEffect } from "react";
import { useLocation } from "react-router";
import { CurrencySection } from "./settings/CurrencySection.tsx";
import { BudgetFoldersSection } from "./settings/BudgetFoldersSection.tsx";
import { PostsSection } from "./settings/PostsSection.tsx";
import { RecurringSection } from "./settings/RecurringSection.tsx";
import { DataSection } from "./settings/DataSection.tsx";
@@ -17,10 +20,23 @@ import { ColophonSection } from "./settings/ColophonSection.tsx";
* "remove" against the card edge.
*/
export function SettingsRoute() {
const location = useLocation();
useEffect(() => {
// BrowserRouter updates the URL without native fragment navigation. Run
// after the settings rows mount, and again for navigation within Settings.
if (!location.hash.startsWith("#bill-")) return;
const target = document.getElementById(location.hash.slice(1));
if (!(target instanceof HTMLButtonElement)) return;
target.click();
target.focus({ preventScroll: true });
target.scrollIntoView({ block: "center" });
}, [location.key, location.hash]);
return (
<div className="space-y-5">
<h1 className="text-2xl">Settings</h1>
<CurrencySection />
<BudgetFoldersSection />
<PostsSection />
<RecurringSection />
<DataSection />
+10
View File
@@ -0,0 +1,10 @@
import { expect, test } from "bun:test";
import { isMonthId } from "./SummaryRoute.tsx";
test("Summary range accepts real months and rejects partial or impossible input", () => {
expect(isMonthId("2026-09")).toBe(true);
expect(isMonthId("")).toBe(false);
expect(isMonthId("2026-0")).toBe(false);
expect(isMonthId("2026-13")).toBe(false);
expect(isMonthId("2026-09-01")).toBe(false);
});
+42 -17
View File
@@ -5,10 +5,19 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useDataset } from "../hooks/useDataset.ts";
import { datasetMonthSpan, summaryView } from "../../domain/views.ts";
import { monthOf } from "../../domain/months.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import { Section, Stat } from "../components/Section.tsx";
import { Segmented } from "../components/Segmented.tsx";
export function isMonthId(value: string): boolean {
try {
return monthOf(value) === value;
} catch {
return false;
}
}
export function SummaryRoute() {
const fmt = useMoneyFormat();
const dataset = useDataset();
@@ -17,9 +26,10 @@ export function SummaryRoute() {
const [to, setTo] = useState(span.to);
const [groupBy, setGroupBy] = useState<"post" | "month">("post");
const view = summaryView(dataset, from, to);
const validFrom = isMonthId(from);
const validTo = isMonthId(to);
const view = validFrom && validTo ? summaryView(dataset, from, to) : null;
const base = dataset.settings.baseCurrency;
const difference = Number((view.totalIncome - view.totalCharges).toFixed(2));
return (
<div className="space-y-5">
@@ -29,11 +39,11 @@ export function SummaryRoute() {
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-1">
<Label htmlFor="from">From</Label>
<Input id="from" className="w-32" value={from} onChange={(e) => setFrom(e.target.value)} />
<Input id="from" className="w-32" value={from} aria-invalid={!validFrom} onChange={(e) => setFrom(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="to">To</Label>
<Input id="to" className="w-32" value={to} onChange={(e) => setTo(e.target.value)} />
<Input id="to" className="w-32" value={to} aria-invalid={!validTo} onChange={(e) => setTo(e.target.value)} />
</div>
<Button
variant="outline"
@@ -46,16 +56,20 @@ export function SummaryRoute() {
</Button>
</div>
<dl className="mt-5 flex flex-wrap gap-x-10 gap-y-4 border-t border-budget-rule pt-5">
<Stat label="Income">{fmt.money(view.totalIncome, base)}</Stat>
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
<Stat label="Difference" tone={difference < 0 ? "overspend" : "default"}>
{fmt.money(difference, base)}
</Stat>
</dl>
{view ? (
<dl className="mt-5 flex flex-wrap gap-x-10 gap-y-4 border-t border-budget-rule pt-5">
<Stat label="Income">{fmt.money(view.totalIncome, base)}</Stat>
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
<Stat label="Difference" tone={view.difference < 0 ? "overspend" : "default"}>
{fmt.money(view.difference, base)}
</Stat>
</dl>
) : (
<p role="alert" className="mt-4 text-sm text-destructive">Enter a month as YYYY-MM in each field.</p>
)}
</Section>
<Section
{view && <Section
title={groupBy === "post" ? "By post" : "By month"}
action={
<Segmented
@@ -79,17 +93,28 @@ export function SummaryRoute() {
</thead>
<tbody>
{groupBy === "post"
? view.byPost.map((entry) => (
? <>
{view.byPost.map((entry) => (
<tr key={entry.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
<td className="py-2.5">{entry.post.name}</td>
<td className="font-money py-2.5 pl-6 text-right">{fmt.amount(entry.charges)}</td>
<td className="font-money py-2.5 pl-6 text-right">{fmt.money(entry.charges, base)}</td>
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
{view.totalCharges === 0
? "—"
: `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`}
</td>
</tr>
))
))}
{view.looseCharges !== 0 && (
<tr className="border-b border-budget-rule text-budget-ink-muted">
<td className="py-2.5">Unassigned</td>
<td className="font-money py-2.5 pl-6 text-right">{fmt.amount(view.looseCharges)}</td>
<td className="font-money py-2.5 pl-6 text-right">
{view.totalCharges === 0 ? "—" : `${((view.looseCharges / view.totalCharges) * 100).toFixed(1)}%`}
</td>
</tr>
)}
</>
: view.byMonth.map((entry) => (
<tr key={entry.monthId} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
<td className="py-2.5">
@@ -97,7 +122,7 @@ export function SummaryRoute() {
{entry.monthId}
</Link>
</td>
<td className="font-money py-2.5 pl-6 text-right">{fmt.amount(entry.charges)}</td>
<td className="font-money py-2.5 pl-6 text-right">{fmt.money(entry.charges, base)}</td>
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
{view.totalCharges === 0
? "—"
@@ -107,7 +132,7 @@ export function SummaryRoute() {
))}
</tbody>
</table>
</Section>
</Section>}
</div>
);
}
+1 -1
View File
@@ -42,7 +42,7 @@ export function YearRoute() {
</Section>
<Section
title="Posts by month"
title={mode === "spend" ? "Spending by month" : "Posts by month"}
action={
<Segmented
label="Figures to show"
@@ -0,0 +1,119 @@
import { expect, test } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { act } from "react";
import { createRoot } from "react-dom/client";
import { createSeedDataset } from "../../../domain/seed.ts";
import { store } from "../../../store/index.ts";
import { addBudgetFolder, addRecurringCost } from "../../../store/actions.ts";
import { BudgetFoldersSection } from "./BudgetFoldersSection.tsx";
async function setup(run: (container: HTMLDivElement, data: ReturnType<typeof createSeedDataset>) => Promise<void>, fail = false) {
let data = createSeedDataset("2026-09");
const housing = addBudgetFolder(data, "Housing", null);
addBudgetFolder(data, "Utilities", housing.id);
addRecurringCost(data, { name: "Phone", folderId: null, archived: false, amount: { amount: 30, currency: "USD" }, startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }] });
data.fxRates = [];
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
const listeners = new Set<() => void>();
store.get = () => data;
store.subscribe = fn => { listeners.add(fn); return () => listeners.delete(fn); };
store.mutate = async change => { if (fail) throw new Error("Disk full"); const draft = structuredClone(data); change(draft); data = draft; listeners.forEach(fn => fn()); };
const container = document.createElement("div"); document.body.append(container);
const root = createRoot(container);
try { act(() => root.render(<BudgetFoldersSection />)); await run(container, data); }
finally { act(() => root.unmount()); container.remove(); Object.assign(store, original); }
}
function select(container: HTMLDivElement, label: string, value: string) {
const field = container.querySelector(`select[aria-label="${label}"]`) as HTMLSelectElement;
field.value = value; field.dispatchEvent(new Event("change", { bubbles: true }));
}
function input(container: HTMLDivElement, label: string, value: string) {
const field = container.querySelector(`input[aria-label="${label}"]`) as HTMLInputElement;
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(field, value);
field.dispatchEvent(new InputEvent("input", { bubbles: true }));
}
test("nested folder can be created and renamed", async () => setup(async (container, data) => {
const housing = data.folders[0]!;
const parent = container.querySelector('select[aria-label="Folder for Housing"]')!;
expect(parent.querySelector(`option[value="${housing.id}"]`)).toBeNull();
expect(parent.querySelector(`option[value="${data.folders[1]!.id}"]`)).toBeNull();
act(() => { input(container, "New folder name", "Services"); select(container, "New folder parent", housing.id); });
await act(async () => { Array.from(container.querySelectorAll("button")).find(b => b.textContent === "Add folder")!.click(); });
const services = store.get().folders.find(f => f.name === "Services")!;
expect(services.parentId).toBe(housing.id);
const field = container.querySelector('input[aria-label="Name for Services"]') as HTMLInputElement;
act(() => { field.focus(); input(container, "Name for Services", "Household"); });
await act(async () => field.blur());
expect(store.get().folders.find(f => f.id === services.id)!.name).toBe("Household");
await act(async () => { select(container, "Folder for Utilities", ""); });
expect(store.get().folders[1]!.parentId).toBeNull();
}));
test("post and bill move independently", async () => setup(async (container, data) => {
const housing = data.folders[0]!, utilities = data.folders[1]!;
const post = data.posts[0]!, bill = data.recurring[0]!;
await act(async () => { select(container, `Folder for ${post.name}`, housing.id); });
await act(async () => { select(container, "Folder for Phone", utilities.id); });
expect(store.get().posts[0]!.folderId).toBe(housing.id);
expect(store.get().recurring[0]!.folderId).toBe(utilities.id);
expect(store.get().recurring[0]!.splits).toEqual(bill.splits);
await act(async () => { (container.querySelector(`button[aria-label="Move ${post.name} up"]`) as HTMLButtonElement).click(); });
expect(store.get().posts[0]!.order).toBe(0);
const rows = Array.from(container.querySelectorAll("[data-budget-item]"));
expect(rows.findIndex(row => row.getAttribute("data-budget-item") === post.id)).toBeLessThan(rows.findIndex(row => row.getAttribute("data-budget-item") === utilities.id));
}));
test("a bill can move beneath a post and follows that post's folder", async () => setup(async (container, data) => {
const post = data.posts[0]!, bill = data.recurring[0]!;
await act(async () => { select(container, "Folder for Phone", `post:${post.id}`); });
expect(store.get().recurring[0]!.parentPostId).toBe(post.id);
expect(store.get().recurring[0]!.folderId).toBeNull();
await act(async () => { select(container, `Folder for ${post.name}`, data.folders[0]!.id); });
expect(store.get().posts[0]!.folderId).toBe(data.folders[0]!.id);
expect(store.get().recurring[0]!.parentPostId).toBe(post.id);
expect(container.querySelector(`[data-budget-item="${post.id}"] + div [data-budget-item="${bill.id}"]`)).not.toBeNull();
}));
test("remove folder moves nested items into its parent", async () => setup(async (container, data) => {
const housing = data.folders[0]!, utilities = data.folders[1]!;
await act(async () => { (container.querySelector('button[aria-label="Remove folder Utilities"]') as HTMLButtonElement).click(); });
expect(store.get().folders.some(folder => folder.id === utilities.id)).toBe(false);
expect(store.get().folders.find(folder => folder.id === housing.id)).toBeDefined();
}));
test("failed move reports error", async () => setup(async (container, data) => {
const before = JSON.stringify(data);
await act(async () => { select(container, `Folder for ${data.posts[0]!.name}`, data.folders[0]!.id); });
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
expect(JSON.stringify(store.get())).toBe(before);
}, true));
test("folder rename keeps multiple keystrokes until explicit save", async () => setup(async (container, data) => {
const persist = store.mutate;
const pending: Array<() => Promise<void>> = [];
store.mutate = change => new Promise<void>((resolve, reject) => {
pending.push(async () => { try { await persist(change); resolve(); } catch (error) { reject(error); } });
});
const field = container.querySelector('input[aria-label="Name for Housing"]') as HTMLInputElement;
act(() => field.focus());
for (const value of ["H", "Ho", "Home", "Home ", "Home bills"]) {
act(() => input(container, "Name for Housing", value));
expect(field.value).toBe(value);
expect(store.get().folders[0]!.name).toBe("Housing");
}
expect(pending.length).toBe(0);
act(() => field.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })));
expect(pending.length).toBe(1);
expect(store.get().folders[0]!.name).toBe("Housing");
await act(async () => { await pending.shift()!(); });
expect(store.get().folders[0]!.name).toBe("Home bills");
expect(store.get().folders[0]!.id).toBe(data.folders[0]!.id);
}));
test("failed folder rename retains draft for retry", async () => setup(async (container) => {
const field = container.querySelector('input[aria-label="Name for Housing"]') as HTMLInputElement;
act(() => { field.focus(); input(container, "Name for Housing", "Home bills"); });
await act(async () => field.blur());
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
expect(store.get().folders[0]!.name).toBe("Housing");
expect(field.value).toBe("Home bills");
}, true));
@@ -0,0 +1,111 @@
import { useRef, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { Section } from "../../components/Section.tsx";
import { useDataset } from "../../hooks/useDataset.ts";
import { useMutate } from "../../hooks/useMutate.ts";
import { addBudgetFolder, budgetSiblings, placeBudgetItem, placeRecurringUnderPost, removeBudgetFolder, renameBudgetFolder, type BudgetItemKey } from "../../../store/actions.ts";
export function BudgetFoldersSection() {
const dataset = useDataset();
const { mutate, error } = useMutate();
const [name, setName] = useState("");
const [parent, setParent] = useState<string | null>(null);
const [folderNames, setFolderNames] = useState<Record<string, string>>({});
const savingNames = useRef(new Set<string>());
function saveFolderName(id: string, savedName: string) {
const typed = folderNames[id];
if (typed === undefined || typed === savedName || savingNames.current.has(id)) return;
savingNames.current.add(id);
void mutate(draft => renameBudgetFolder(draft, id, typed)).then(saved => {
savingNames.current.delete(id);
if (saved) setFolderNames(names => {
if (names[id] !== typed) return names;
const next = { ...names };
delete next[id];
return next;
});
});
}
function folderPath(id: string): string {
const folder = dataset.folders.find(folder => folder.id === id)!;
return folder.parentId === null ? folder.name : `${folderPath(folder.parentId)} / ${folder.name}`;
}
const folders = [...dataset.folders].sort((a, b) => folderPath(a.id).localeCompare(folderPath(b.id)));
function allowedDestination(key: BudgetItemKey, id: string): boolean {
if (key.kind !== "folder") return true;
let cursor: string | null = id;
while (cursor !== null) {
if (cursor === key.id) return false;
cursor = dataset.folders.find(folder => folder.id === cursor)!.parentId;
}
return true;
}
function children(parentId: string | null, parentPostId?: string): ReactNode {
const siblings = parentPostId ? dataset.recurring.filter(cost => cost.parentPostId === parentPostId).sort((a, b) => a.order - b.order).map(cost => ({ kind: "recurring" as const, id: cost.id, order: cost.order })) : budgetSiblings(dataset, parentId);
return <ul className="space-y-2">{siblings.map((key, index) => {
const item = key.kind === "folder" ? dataset.folders.find(item => item.id === key.id)! : key.kind === "post" ? dataset.posts.find(item => item.id === key.id)! : key.kind === "purchase" ? dataset.purchases.find(item => item.id === key.id)! : dataset.recurring.find(item => item.id === key.id)!;
const name = "description" in item ? item.description : item.name;
return <li key={`${key.kind}-${key.id}`}>
<div data-budget-item={key.id} className="flex flex-wrap items-center gap-2 border-b border-budget-rule py-2">
<div className="min-w-0 flex-1 basis-40">
{key.kind === "folder" ? <Input className="h-8 w-full max-w-64" aria-label={`Name for ${name}`} value={folderNames[key.id] ?? name} onInput={event => {
const typed = event.currentTarget.value;
setFolderNames(names => ({ ...names, [key.id]: typed }));
}} onBlur={() => saveFolderName(key.id, name)} onKeyDown={event => {
if (event.key === "Enter") {
event.preventDefault();
saveFolderName(key.id, name);
}
}} /> : <span className="break-words text-sm">{name}</span>}
<span className="mt-1 block text-xs text-budget-ink-muted">{key.kind === "folder" ? "Folder" : key.kind === "post" ? "Post" : key.kind === "purchase" ? "Purchase" : "Bill"}</span>
</div>
<NativeSelect className="h-8 w-full sm:w-56 text-xs md:text-xs" aria-label={`Folder for ${name}`} value={parentPostId ? `post:${parentPostId}` : parentId ?? ""} onChange={event => {
const destination = event.target.value || null;
void mutate(draft => {
if (key.kind === "recurring" && destination?.startsWith("post:")) placeRecurringUnderPost(draft, key.id, destination.slice(5));
else placeBudgetItem(draft, key, destination, budgetSiblings(draft, destination).filter(sibling => !(sibling.kind === key.kind && sibling.id === key.id)).length);
});
}}>
<option value="">Unfiled</option>
{folders.filter(folder => allowedDestination(key, folder.id)).map(folder => <option key={folder.id} value={folder.id}>{folderPath(folder.id)}</option>)}
{key.kind === "recurring" && dataset.posts.map(post => <option key={post.id} value={`post:${post.id}`}>Under {post.name}</option>)}
</NativeSelect>
<div className="flex gap-1">
<Button size="xs" variant="ghost" aria-label={`Move ${name} up`} disabled={index === 0 || !!parentPostId} onClick={() => void mutate(draft => {
const position = budgetSiblings(draft, parentId).findIndex(sibling => sibling.kind === key.kind && sibling.id === key.id);
placeBudgetItem(draft, key, parentId, position - 1);
})}>up</Button>
<Button size="xs" variant="ghost" aria-label={`Move ${name} down`} disabled={index === siblings.length - 1 || !!parentPostId} onClick={() => void mutate(draft => {
const position = budgetSiblings(draft, parentId).findIndex(sibling => sibling.kind === key.kind && sibling.id === key.id);
placeBudgetItem(draft, key, parentId, position + 1);
})}>down</Button>
{key.kind === "folder" && <Button size="xs" variant="ghost" aria-label={`Remove folder ${name}`} title="Move contents to parent and remove folder" onClick={() => void mutate(draft => removeBudgetFolder(draft, key.id))}>remove</Button>}
</div>
</div>
{key.kind === "folder" && <div className="ml-3 border-l border-budget-rule pl-3 sm:ml-5 sm:pl-5">{children(key.id)}</div>}
{key.kind === "post" && dataset.recurring.some(cost => cost.parentPostId === key.id) && <div className="ml-3 border-l border-budget-rule pl-3 sm:ml-5 sm:pl-5">{children(parentId, key.id)}</div>}
</li>;
})}</ul>;
}
return <div id="budget-folders" className="scroll-mt-5"><Section title="Budget folders" hint="Organize folders, posts, bills, and placed purchases together. Moving a bill or purchase leaves its post charges unchanged.">
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not save: {error}</p>}
<h3 className="text-sm font-medium">Top level · Unfiled</h3>
{children(null)}
<div className="mt-5 flex flex-wrap items-end gap-2 border-t border-budget-rule pt-5">
<label className="text-xs">Folder name<Input className="mt-1 w-48" aria-label="New folder name" value={name} onInput={event => setName(event.currentTarget.value)} /></label>
<label className="text-xs">Parent folder<NativeSelect className="mt-1 w-56" aria-label="New folder parent" value={parent ?? ""} onChange={event => setParent(event.target.value || null)}>
<option value="">Unfiled</option>{folders.map(folder => <option key={folder.id} value={folder.id}>{folderPath(folder.id)}</option>)}
</NativeSelect></label>
<Button variant="outline" disabled={!name.trim()} onClick={() => {
const newName = name.trim(), destination = parent;
void mutate(draft => { addBudgetFolder(draft, newName, destination); }).then(saved => { if (saved) setName(""); });
}}>Add folder</Button>
</div>
</Section></div>;
}
+4 -4
View File
@@ -1,9 +1,9 @@
import type { ReactNode } from "react";
import { useDataset } from "../../hooks/useDataset.ts";
import { BUILD_VERSION } from "../../buildVersion.ts";
import { BUILD_VERSION, COMMIT_HREF, SOURCE_REPOSITORY } from "../../buildVersion.ts";
import { Section } from "../../components/Section.tsx";
const REPO = "github.com/StereotypicalCat/budget2.0";
const REPO = SOURCE_REPOSITORY.replace(/^https?:\/\//, "");
/**
* Production notes, in the old sense of the word: where this came from, what it
@@ -29,7 +29,7 @@ export function ColophonSection() {
whole position is that the data never leaves the browser should
not hand this page's URL to another origin on the way out. */}
<a
href={`https://${REPO}`}
href={SOURCE_REPOSITORY}
target="_blank"
rel="noopener noreferrer"
className="text-budget-accent underline underline-offset-2 hover:no-underline"
@@ -51,7 +51,7 @@ export function ColophonSection() {
<span className="font-money">{dataset.settings.schemaVersion}</span>
</Entry>
<Entry label="Build">
<span className="font-money">{BUILD_VERSION}</span>
{COMMIT_HREF ? <a href={COMMIT_HREF} target="_blank" rel="noopener noreferrer" className="font-money text-budget-accent underline underline-offset-2 hover:no-underline">{BUILD_VERSION}</a> : <span className="font-money">{BUILD_VERSION}</span>}
</Entry>
</dl>
</Section>
+11 -3
View File
@@ -21,7 +21,7 @@ import type { Currency } from "../../../domain/types.ts";
export function CurrencySection() {
const dataset = useDataset();
const { mutate } = useMutate();
const { mutate, error: writeError } = useMutate();
const base = dataset.settings.baseCurrency;
const apiUrl = dataset.settings.fxApiUrl ?? DEFAULT_FX_API_URL;
const [status, setStatus] = useState<string | null>(null);
@@ -32,9 +32,13 @@ export function CurrencySection() {
setStatus("Fetching…");
try {
const rates = await fetchFxRates(apiUrl, base, targets);
mutate((draft) => {
const saved = await mutate((draft) => {
for (const rate of rates) setFxRate(draft, rate);
});
if (!saved) {
setStatus(null);
return;
}
const missing = targets.filter((t) => !rates.some((r) => r.currency === t));
setStatus(
`Updated ${rates.length} rate(s).` +
@@ -67,7 +71,11 @@ export function CurrencySection() {
title="Currencies"
hint="Add any currency you spend in. The code is how it is identified, so it cannot be changed later — and everything is converted to the base currency for totals."
>
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
{(error || writeError) && (
<p role="alert" className="mb-3 text-sm text-destructive">
{error ?? `Could not save: ${writeError}`}
</p>
)}
<div className="mb-5 flex flex-wrap items-end gap-x-8 gap-y-4">
<div className="space-y-1.5">
@@ -0,0 +1,65 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, expect, test } from "bun:test";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { createSeedDataset } from "../../../domain/seed.ts";
import { store } from "../../../store/index.ts";
import { PostsSection } from "./PostsSection.tsx";
const originalGet = store.get;
const originalMutate = store.mutate;
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
store.get = originalGet;
store.mutate = originalMutate;
if (root) act(() => root!.unmount());
root = null;
container?.remove();
container = null;
});
test("a failed post archive explains why the post stayed active", async () => {
const dataset = createSeedDataset("2026-01");
store.get = () => dataset;
store.mutate = async () => { throw new Error("Disk full"); };
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root!.render(<PostsSection />));
await act(async () => {
Array.from(container!.querySelectorAll("button"))
.find((button) => button.textContent === "archive")!.click();
});
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
expect(dataset.posts[0]!.archived).toBe(false);
});
test("a successful retry clears the previous save error", async () => {
const dataset = createSeedDataset("2026-01");
store.get = () => dataset;
let fail = true;
store.mutate = async (change) => {
if (fail) throw new Error("Disk full");
change(dataset);
};
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root!.render(<PostsSection />));
const archive = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent === "archive")!;
await act(async () => { archive.click(); });
expect(container.querySelector('[role="alert"]')).not.toBeNull();
fail = false;
await act(async () => { archive.click(); });
expect(container.querySelector('[role="alert"]')).toBeNull();
});
+12 -17
View File
@@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { useDataset } from "../../hooks/useDataset.ts";
import { useMutate } from "../../hooks/useMutate.ts";
import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts";
import { addPost, setPostArchived, updatePost } from "../../../store/actions.ts";
import type { Currency, Post } from "../../../domain/types.ts";
import { ruleAt } from "../../../domain/allocation.ts";
import { currentMonth } from "../../../store/index.ts";
@@ -13,7 +13,7 @@ import { Section } from "../../components/Section.tsx";
export function PostsSection() {
const dataset = useDataset();
const { mutate } = useMutate();
const { mutate, error } = useMutate();
const [newName, setNewName] = useState("");
const [openHistory, setOpenHistory] = useState<string | null>(null);
const ordered = [...dataset.posts].sort((a, b) => a.order - b.order);
@@ -40,7 +40,10 @@ export function PostsSection() {
title="Posts"
hint="A post's allocation is a dated series: each rule applies from its own month onward until the next one takes over, so changing it never rewrites what earlier months got. Click a rule to see and edit that history. A single month can still override its own allocation from the month view, and percentages may total more than 100%."
>
<table className="w-full text-sm">
<p className="mb-3 text-xs"><a className="underline underline-offset-2" href="#budget-folders">Move and reorder posts in Budget folders</a></p>
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not save: {error}</p>}
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] text-sm">
<thead className="text-left">
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
<th className="py-2 font-medium">Name</th>
@@ -50,7 +53,7 @@ export function PostsSection() {
</tr>
</thead>
<tbody>
{ordered.map((post, index) => (
{ordered.map((post) => (
<Fragment key={post.id}>
<tr className={`border-b last:border-0 ${post.archived ? "opacity-50" : ""}`}>
<td className="py-2">
@@ -100,18 +103,7 @@ export function PostsSection() {
</Button>
</td>
<td className="py-2">
{/* Three inline-flex siblings with nothing between them: the
hover backgrounds touched, so the row read as one wide
slab rather than three actions. */}
<div className="flex items-center justify-end gap-1">
<Button size="xs" variant="ghost" disabled={index === 0}
onClick={() => mutate((draft) => movePost(draft, post.id, -1))}>
up
</Button>
<Button size="xs" variant="ghost" disabled={index === ordered.length - 1}
onClick={() => mutate((draft) => movePost(draft, post.id, 1))}>
down
</Button>
<Button size="xs" variant="ghost" className="-mr-2"
onClick={() => mutate((draft) => setPostArchived(draft, post.id, !post.archived))}>
{post.archived ? "restore" : "archive"}
@@ -130,6 +122,7 @@ export function PostsSection() {
))}
</tbody>
</table>
</div>
<p className="mt-3 max-w-[70ch] text-xs leading-relaxed text-budget-ink-muted">
Archiving hides a post from new purchases while keeping its history. Posts
are never deleted, because purchases reference them.
@@ -146,8 +139,10 @@ export function PostsSection() {
variant="outline"
disabled={newName.trim() === ""}
onClick={() => {
mutate((draft) => addPost(draft, newName.trim(), base));
setNewName("");
const name = newName.trim();
void mutate((draft) => addPost(draft, name, base)).then((saved) => {
if (saved) setNewName("");
});
}}
>
Add post
@@ -1,175 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
isValidEndedFrom,
isValidStartDate,
normalizedStartDateFor,
splitChangesOf,
splitTargetOf,
} from "./RecurringSection.tsx";
// C2: the "Starts" field must never be able to persist an empty or
// malformed value — `isValidStartDate` is the guard the onBlur handler uses
// to decide whether to call `mutate` at all.
describe("isValidStartDate", () => {
test("an empty string is never valid, for any kind", () => {
expect(isValidStartDate("", "everyNMonths", "calendar")).toBe(false);
expect(isValidStartDate("", "everyNDays", "calendar")).toBe(false);
expect(isValidStartDate("", "everyNWeeks", "calendar")).toBe(false);
});
test("everyNMonths accepts either granularity", () => {
expect(isValidStartDate("2026-09", "everyNMonths", "calendar")).toBe(true);
expect(isValidStartDate("2026-09-17", "everyNMonths", "calendar")).toBe(true);
});
test("everyNMonths still rejects garbage", () => {
expect(isValidStartDate("not-a-date", "everyNMonths", "calendar")).toBe(false);
expect(isValidStartDate("2026-13", "everyNMonths", "calendar")).toBe(false);
});
test("a day-granular kind rejects a month-only value", () => {
// This is the exact C1/C2 crash: monthOf() would happily hand this back,
// but occurrencesOf hands it to addDays, which throws.
expect(isValidStartDate("2026-09", "everyNDays", "calendar")).toBe(false);
expect(isValidStartDate("2026-09", "everyNWeeks", "calendar")).toBe(false);
});
test("a day-granular kind accepts a day-granular value", () => {
expect(isValidStartDate("2026-09-17", "everyNDays", "calendar")).toBe(true);
expect(isValidStartDate("2026-09-17", "everyNWeeks", "calendar")).toBe(true);
});
// The field asks the same predicate the store does, so it refuses exactly
// what the write would refuse. Anchoring is half of that rule: an
// everyNMonths cost measured from its last charge steps day-precisely, so
// a month-only value there is a doomed mutate, not a valid entry.
test("everyNMonths rejects a month-only value once it is anchored to lastCharge", () => {
expect(isValidStartDate("2026-09", "everyNMonths", "lastCharge")).toBe(false);
expect(isValidStartDate("2026-09-17", "everyNMonths", "lastCharge")).toBe(true);
});
test("a day-granular kind rejects a malformed day-granular-shaped value", () => {
expect(isValidStartDate("2026-02-30", "everyNDays", "calendar")).toBe(false); // no Feb 30
});
// Residual C1: `monthOf` only extracts the month and never checks the day,
// so a day-SHAPED but calendar-impossible value ("2026-09-31" — September
// has 30 days) used to pass here even under everyNMonths, and could then be
// stored, only for foldBalances to throw three months later.
test("everyNMonths still rejects a day-granular value with an impossible day", () => {
expect(isValidStartDate("2026-09-31", "everyNMonths", "calendar")).toBe(false);
});
test("everyNMonths accepts a real leap-day and rejects a non-leap Feb 29", () => {
expect(isValidStartDate("2024-02-29", "everyNMonths", "calendar")).toBe(true); // 2024 is a leap year
expect(isValidStartDate("2026-02-29", "everyNMonths", "calendar")).toBe(false); // 2026 is not
expect(isValidStartDate("2026-02-28", "everyNMonths", "calendar")).toBe(true);
});
});
// GAP 2 (spec §10): "Ends" needs no granularity rule (unlike "Starts"), but
// still has to be a real calendar date, and empty must be accepted since it
// is how the field un-ends a cost.
describe("isValidEndedFrom", () => {
test("an empty string is valid — it means 'not ended'", () => {
expect(isValidEndedFrom("")).toBe(true);
});
test("both granularities are valid, unconditionally — no recurrence kind is involved", () => {
expect(isValidEndedFrom("2026-09")).toBe(true);
expect(isValidEndedFrom("2026-09-17")).toBe(true);
});
test("garbage is rejected", () => {
expect(isValidEndedFrom("not-a-date")).toBe(false);
expect(isValidEndedFrom("2026-13")).toBe(false);
});
test("a calendar-impossible date is rejected, day- or month-shaped", () => {
expect(isValidEndedFrom("2026-02-30")).toBe(false); // no Feb 30
expect(isValidEndedFrom("2026-02-29")).toBe(false); // 2026 is not a leap year
});
test("a real leap day is accepted", () => {
expect(isValidEndedFrom("2024-02-29")).toBe(true);
});
});
// C1: switching the unit dropdown to a day-granular kind must expand a
// month-only startDate in the same mutate call, never leaving the pairing
// unvalidated for even one render.
describe("normalizedStartDateFor", () => {
test("expands a month-only date when switching to a day-granular kind", () => {
expect(normalizedStartDateFor("everyNDays", "2026-09", "calendar")).toBe("2026-09-01");
expect(normalizedStartDateFor("everyNWeeks", "2026-09", "calendar")).toBe("2026-09-01");
});
test("leaves an already day-granular date untouched", () => {
expect(normalizedStartDateFor("everyNDays", "2026-09-17", "calendar")).toBe("2026-09-17");
});
test("leaves the date untouched when switching to everyNMonths", () => {
expect(normalizedStartDateFor("everyNMonths", "2026-09", "calendar")).toBe("2026-09");
expect(normalizedStartDateFor("everyNMonths", "2026-09-17", "calendar")).toBe("2026-09-17");
});
// The carried anchoring item: an everyNMonths cost switching to lastCharge
// needs the same expansion the kind handler already gets, because
// requireStartDateGranularity's needsDay turns true the moment anchoring is
// lastCharge, regardless of kind.
test("expands a month-only date when switching everyNMonths to lastCharge anchoring", () => {
expect(normalizedStartDateFor("everyNMonths", "2026-09", "lastCharge")).toBe("2026-09-01");
});
test("leaves the date untouched switching to calendar anchoring", () => {
expect(normalizedStartDateFor("everyNMonths", "2026-09", "calendar")).toBe("2026-09");
});
test("leaves an already day-granular date untouched under lastCharge anchoring", () => {
expect(normalizedStartDateFor("everyNMonths", "2026-09-17", "lastCharge")).toBe("2026-09-17");
});
});
describe("a cost's splits are editable", () => {
test("splitTargetOf exposes the fields SplitEditor needs", () => {
const cost = {
id: "r1", name: "Rent", order: 0, archived: false,
amount: { amount: 8200, currency: "DKK" },
startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 1 },
anchoring: "calendar", splitMode: "percent",
splits: [{ postId: "a", value: 100, absorbsRemainder: true }],
} as const;
expect(splitTargetOf(cost)).toEqual({
amount: 8200,
currency: "DKK",
splitMode: "percent",
splits: [{ postId: "a", value: 100, absorbsRemainder: true }],
});
});
test("splitChangesOf turns an edited target back into a cost update", () => {
const next = {
amount: 8200, currency: "DKK", splitMode: "fixed" as const,
splits: [
{ postId: "a", value: 5000, absorbsRemainder: false },
{ postId: "b", value: 3200, absorbsRemainder: true },
],
};
expect(splitChangesOf(next)).toEqual({
splitMode: "fixed",
splits: [
{ postId: "a", value: 5000, absorbsRemainder: false },
{ postId: "b", value: 3200, absorbsRemainder: true },
],
});
});
test("splitChangesOf does not carry the amount back", () => {
// The amount has its own field in the table; letting the split editor
// write it too would give one value two owners.
expect(splitChangesOf({
amount: 999, currency: "DKK", splitMode: "percent",
splits: [{ postId: "a", value: 100, absorbsRemainder: true }],
})).not.toHaveProperty("amount");
});
});
@@ -0,0 +1,225 @@
import { describe, expect, test } from "bun:test";
import { isValidEndedFrom, isValidStartDate, normalizedStartDateFor } from "./RecurringSection.tsx";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { act } from "react";
import { createRoot } from "react-dom/client";
import { createSeedDataset } from "../../../domain/seed.ts";
import { store } from "../../../store/index.ts";
import { addRecurringCost } from "../../../store/actions.ts";
import { RecurringSection } from "./RecurringSection.tsx";
test("existing post destination requires a selection", async () => {
const data = createSeedDataset("2026-09");
data.posts[0]!.archived = true;
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
store.get = () => data;
store.subscribe = () => () => {};
store.mutate = async change => { change(data); };
const container = document.createElement("div");
const root = createRoot(container);
document.body.append(container);
try {
act(() => root.render(<RecurringSection />));
const name = container.querySelector('input[placeholder="New cost name"]') as HTMLInputElement;
act(() => { name.focus(); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(name, "Phone"); name.dispatchEvent(new InputEvent("input", { bubbles: true, data: "Phone", inputType: "insertText" })); });
const kind = container.querySelector('select[aria-label="Destination type"]') as HTMLSelectElement;
act(() => { kind.value = "existing"; kind.dispatchEvent(new Event("change", { bubbles: true })); });
const add = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Add recurring cost")!;
expect(add.disabled).toBe(true);
const chosen = data.posts[1]!;
const destination = container.querySelector('select[aria-label="Destination post"]') as HTMLSelectElement;
expect(destination.querySelector(`option[value="${data.posts[0]!.id}"]`)).toBeNull();
act(() => { destination.value = chosen.id; destination.dispatchEvent(new Event("change", { bubbles: true })); });
const amount = container.querySelector('input[aria-label="New bill amount"]') as HTMLInputElement;
act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(amount, "30$"); amount.dispatchEvent(new InputEvent("input", { bubbles: true, data: "30$", inputType: "insertText" })); });
expect(add.disabled).toBe(false);
await act(async () => { add.click(); });
const saved = data.recurring[0]!;
expect(saved.splits[0]!.postId).toBe(chosen.id);
expect(saved.parentPostId).toBe(chosen.id);
expect(saved.amount).toEqual({ amount: 30, currency: "USD" });
} finally {
act(() => root.unmount()); container.remove();
store.get = original.get; store.subscribe = original.subscribe; store.mutate = original.mutate;
}
});
test("new bill defaults to a same-name post and accepts schedule controls", async () => {
const data = createSeedDataset("2026-09");
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
store.get = () => data;
store.subscribe = () => () => {};
store.mutate = async change => { change(data); };
const container = document.createElement("div");
const root = createRoot(container);
document.body.append(container);
try {
act(() => root.render(<RecurringSection />));
expect((container.querySelector('select[aria-label="Destination type"]') as HTMLSelectElement).value).toBe("dedicated");
expect((container.querySelector('input[aria-label="New bill amount"]') as HTMLInputElement).value).toContain(data.settings.baseCurrency);
expect((container.querySelector('input[aria-label="New bill start date"]') as HTMLInputElement).type).toBe("month");
const precision = container.querySelector('select[aria-label="New bill date precision"]') as HTMLSelectElement;
act(() => { precision.value = "date"; precision.dispatchEvent(new Event("change", { bubbles: true })); });
expect((container.querySelector('input[aria-label="New bill start date"]') as HTMLInputElement).type).toBe("date");
const name = container.querySelector('input[placeholder="New cost name"]') as HTMLInputElement;
act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(name, "Insurance"); name.dispatchEvent(new InputEvent("input", { bubbles: true })); });
const amount = container.querySelector('input[aria-label="New bill amount"]') as HTMLInputElement;
act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(amount, "1200 DKK"); amount.dispatchEvent(new InputEvent("input", { bubbles: true })); });
const interval = container.querySelector('input[aria-label="New bill interval"]') as HTMLInputElement;
act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(interval, "12"); interval.dispatchEvent(new InputEvent("input", { bubbles: true })); });
await act(async () => { Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Add recurring cost")!.click(); });
const bill = data.recurring[0]!;
const post = data.posts.find(post => post.id === bill.splits[0]!.postId)!;
expect(post.name).toBe("Insurance");
expect(bill.parentPostId).toBe(post.id);
expect(bill.recurrence).toEqual({ kind: "everyNMonths", n: 12 });
expect(bill.startDate).toHaveLength(10);
expect(post.rules[0]!.rule).toEqual({ kind: "fixed", amount: { amount: 100, currency: "DKK" } });
} finally {
act(() => root.unmount()); container.remove();
Object.assign(store, original);
}
});
test("failed destination edit keeps its editor and error visible", async () => {
const data = createSeedDataset("2026-09");
const cost = addRecurringCost(data, {
name: "Phone", folderId: null, archived: false,
amount: { amount: 30, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
});
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
store.get = () => data;
store.subscribe = () => () => {};
store.mutate = async () => { throw new Error("Disk full"); };
const container = document.createElement("div");
const root = createRoot(container);
document.body.append(container);
try {
act(() => root.render(<RecurringSection />));
const edit = container.querySelector(`#bill-${cost.id}`) as HTMLButtonElement;
expect(edit).not.toBeNull();
act(() => edit.click());
await act(async () => { (Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Save destinations")!).click(); });
expect(container.textContent).toContain("Save destinations");
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
expect(cost.splits[0]!.postId).toBe(data.posts[0]!.id);
} finally {
act(() => root.unmount()); container.remove();
store.get = original.get; store.subscribe = original.subscribe; store.mutate = original.mutate;
}
});
// C2: the "Starts" field must never be able to persist an empty or
// malformed value — `isValidStartDate` is the guard the onBlur handler uses
// to decide whether to call `mutate` at all.
describe("isValidStartDate", () => {
test("an empty string is never valid, for any kind", () => {
expect(isValidStartDate("", "everyNMonths", "calendar")).toBe(false);
expect(isValidStartDate("", "everyNDays", "calendar")).toBe(false);
expect(isValidStartDate("", "everyNWeeks", "calendar")).toBe(false);
});
test("everyNMonths accepts either granularity", () => {
expect(isValidStartDate("2026-09", "everyNMonths", "calendar")).toBe(true);
expect(isValidStartDate("2026-09-17", "everyNMonths", "calendar")).toBe(true);
});
test("everyNMonths still rejects garbage", () => {
expect(isValidStartDate("not-a-date", "everyNMonths", "calendar")).toBe(false);
expect(isValidStartDate("2026-13", "everyNMonths", "calendar")).toBe(false);
});
test("a day-granular kind rejects a month-only value", () => {
// This is the exact C1/C2 crash: monthOf() would happily hand this back,
// but occurrencesOf hands it to addDays, which throws.
expect(isValidStartDate("2026-09", "everyNDays", "calendar")).toBe(false);
expect(isValidStartDate("2026-09", "everyNWeeks", "calendar")).toBe(false);
});
test("a day-granular kind accepts a day-granular value", () => {
expect(isValidStartDate("2026-09-17", "everyNDays", "calendar")).toBe(true);
expect(isValidStartDate("2026-09-17", "everyNWeeks", "calendar")).toBe(true);
});
test("a day-granular kind rejects a malformed day-granular-shaped value", () => {
expect(isValidStartDate("2026-02-30", "everyNDays", "calendar")).toBe(false); // no Feb 30
});
// Residual C1: `monthOf` only extracts the month and never checks the day,
// so a day-SHAPED but calendar-impossible value ("2026-09-31" — September
// has 30 days) used to pass here even under everyNMonths, and could then be
// stored, only for foldBalances to throw three months later.
test("everyNMonths still rejects a day-granular value with an impossible day", () => {
expect(isValidStartDate("2026-09-31", "everyNMonths", "calendar")).toBe(false);
});
test("everyNMonths accepts a real leap-day and rejects a non-leap Feb 29", () => {
expect(isValidStartDate("2024-02-29", "everyNMonths", "calendar")).toBe(true); // 2024 is a leap year
expect(isValidStartDate("2026-02-29", "everyNMonths", "calendar")).toBe(false); // 2026 is not
expect(isValidStartDate("2026-02-28", "everyNMonths", "calendar")).toBe(true);
});
});
// GAP 2 (spec §10): "Ends" needs no granularity rule (unlike "Starts"), but
// still has to be a real calendar date, and empty must be accepted since it
// is how the field un-ends a cost.
describe("isValidEndedFrom", () => {
test("an empty string is valid — it means 'not ended'", () => {
expect(isValidEndedFrom("")).toBe(true);
});
test("both granularities are valid, unconditionally — no recurrence kind is involved", () => {
expect(isValidEndedFrom("2026-09")).toBe(true);
expect(isValidEndedFrom("2026-09-17")).toBe(true);
});
test("garbage is rejected", () => {
expect(isValidEndedFrom("not-a-date")).toBe(false);
expect(isValidEndedFrom("2026-13")).toBe(false);
});
test("a calendar-impossible date is rejected, day- or month-shaped", () => {
expect(isValidEndedFrom("2026-02-30")).toBe(false); // no Feb 30
expect(isValidEndedFrom("2026-02-29")).toBe(false); // 2026 is not a leap year
});
test("a real leap day is accepted", () => {
expect(isValidEndedFrom("2024-02-29")).toBe(true);
});
});
// C1: switching the unit dropdown to a day-granular kind must expand a
// month-only startDate in the same mutate call, never leaving the pairing
// unvalidated for even one render.
describe("normalizedStartDateFor", () => {
test("expands a month-only date when switching to a day-granular kind", () => {
expect(normalizedStartDateFor("everyNDays", "2026-09", "calendar")).toBe("2026-09-01");
expect(normalizedStartDateFor("everyNWeeks", "2026-09", "calendar")).toBe("2026-09-01");
});
test("leaves an already day-granular date untouched", () => {
expect(normalizedStartDateFor("everyNDays", "2026-09-17", "calendar")).toBe("2026-09-17");
});
test("leaves the date untouched when switching to everyNMonths", () => {
expect(normalizedStartDateFor("everyNMonths", "2026-09", "calendar")).toBe("2026-09");
expect(normalizedStartDateFor("everyNMonths", "2026-09-17", "calendar")).toBe("2026-09-17");
});
// Anchoring is half of the day-precision rule: an everyNMonths cost
// measured from its last charge steps day-precisely, so switching one to
// lastCharge needs the same expansion a kind flip gets.
test("expands a month-only date when switching everyNMonths to lastCharge anchoring", () => {
expect(normalizedStartDateFor("everyNMonths", "2026-09", "lastCharge")).toBe("2026-09-01");
expect(normalizedStartDateFor("everyNMonths", "2026-09-17", "lastCharge")).toBe("2026-09-17");
});
});
test("isValidStartDate rejects a month-only value once everyNMonths is anchored to lastCharge", () => {
expect(isValidStartDate("2026-09", "everyNMonths", "lastCharge")).toBe(false);
expect(isValidStartDate("2026-09-17", "everyNMonths", "lastCharge")).toBe(true);
});
+174 -158
View File
@@ -6,8 +6,9 @@ import { useDataset } from "../../hooks/useDataset.ts";
import { useMutate } from "../../hooks/useMutate.ts";
import {
addRecurringCost,
addRecurringCostWithPost,
placeRecurringUnderPost,
endRecurringCost,
moveRecurringCost,
restoreRecurringCost,
setRecurringCostEndedFrom,
updateRecurringCost,
@@ -15,12 +16,11 @@ import {
import { parseMoneyInput } from "../../moneyInput.ts";
import { currentMonth } from "../../../store/index.ts";
import { Section } from "../../components/Section.tsx";
import { SplitEditor } from "../../components/SplitEditor.tsx";
import type { SplitTarget } from "../../purchaseForm.ts";
import { RecurringSplitEditor, type RecurringSplitSelection } from "../../components/RecurringSplitEditor.tsx";
import { monthOf } from "../../../domain/months.ts";
import { toDayOrdinal } from "../../../domain/days.ts";
import { startDateNeedsDay } from "../../../domain/granularity.ts";
import type { IsoDate, Recurrence, RecurringCost, Split } from "../../../domain/types.ts";
import type { IsoDate, Recurrence, RecurringCost } from "../../../domain/types.ts";
/** Also used by `PurchaseDialog`'s "+ New recurring cost…" control. */
export const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
@@ -31,13 +31,11 @@ export function isDayGranular(date: IsoDate): boolean {
}
/**
* True only for a string this cost's SCHEDULE could actually walk: whether
* that needs a day is `startDateNeedsDay`, the same predicate the store's own
* validation asks, so the field refuses here exactly what the write would
* refuse rather than issuing a doomed mutate. Only "everyNMonths" anchored to
* "calendar" tolerates a month-only value (the walk normalises it through
* `monthOf`); everything else reaches `addDays` or `addMonthsToDate`. Reuses
* the domain's own parsers rather than a second, possibly-diverging regex.
* True only for a string this cost's recurrence kind could actually walk:
* `everyNMonths` tolerates either granularity (the walk normalises through
* `monthOf`), the other two require a day-granular date because they reach
* `addDays` directly. Reuses the domain's own parsers rather than a second,
* possibly-diverging regex.
*
* A day-SHAPED value is routed through `toDayOrdinal` even under
* `everyNMonths`: `monthOf` only extracts the month and never checks the day,
@@ -46,15 +44,17 @@ export function isDayGranular(date: IsoDate): boolean {
* months later out of the fold.
*
* Exported for `RecurringSection.test.ts` — this is the one thing standing
* between the "Starts" field and an empty or malformed value bricking every
* route the fold touches, so it is tested directly rather than only through a
* DOM interaction.
* between the "Starts" field and C2 (an empty or malformed value bricking
* every route the fold touches), so it is tested directly rather than only
* through a DOM interaction.
*/
export function isValidStartDate(
value: string,
kind: Recurrence["kind"],
anchoring: RecurringCost["anchoring"],
): boolean {
// The same predicate the store's validation asks, so the field refuses
// exactly what the write would refuse rather than issuing a doomed mutate.
if (startDateNeedsDay(kind, anchoring) && !isDayGranular(value)) return false;
try {
if (isDayGranular(value)) toDayOrdinal(value);
@@ -94,15 +94,10 @@ export function isValidEndedFrom(value: string): boolean {
* What the "Starts" value becomes when the owner switches to a day-granular
* unit, or to `lastCharge` anchoring: a month-only value expands to the first
* of that month, in the SAME mutate call that changes the kind or anchoring —
* the walk must never see the old pairing even for one render.
*
* Which pairings need a day is `startDateNeedsDay` in
* `domain/granularity.ts` — the same predicate the store's own validation
* asks, so this control cannot expand a date the store would then refuse, or
* leave one it would. `anchoring` is REQUIRED for that reason: it was
* optional once and the kind handler omitted it, which silently answered
* "calendar" for a cost anchored to its last charge. An already day-granular
* value passes through untouched.
* the walk must never see the old pairing even for one render. Which pairings
* need a day is `startDateNeedsDay`, the predicate the store asks; `anchoring`
* is required so a caller cannot silently answer "calendar" for an anchored
* cost. An already day-granular value passes through untouched.
*
* Exported for `RecurringSection.test.ts`.
*/
@@ -115,54 +110,37 @@ export function normalizedStartDateFor(
return `${startDate}-01`;
}
/**
* `splits` is typed `readonly Split[]` rather than `RecurringCost`'s own
* mutable `Split[]` so a `const`-asserted test fixture (whose array is
* `readonly` all the way down) is assignable here — a real stored cost's
* mutable array is assignable to a readonly parameter just as freely.
*/
type SplitSource = Pick<RecurringCost, "amount" | "splitMode"> & {
splits: readonly Split[];
};
/** A cost, viewed as the split editor sees it. */
export function splitTargetOf(cost: SplitSource): SplitTarget {
return {
amount: cost.amount.amount,
currency: cost.amount.currency,
splitMode: cost.splitMode,
splits: cost.splits.map((s) => ({ ...s })),
};
}
/**
* The edited target, as an update to the cost. The amount is deliberately NOT
* carried back: it has its own column in the table, and one value with two
* owners is how a form starts fighting itself.
*/
export function splitChangesOf(next: SplitTarget): Partial<RecurringCost> {
return { splitMode: next.splitMode, splits: next.splits.map((s) => ({ ...s })) };
function dateForInput(value: IsoDate | undefined, precision: "month" | "date"): string {
if (!value) return "";
return precision === "date" ? (isDayGranular(value) ? value : `${value}-01`) : value.slice(0, 7);
}
export function RecurringSection() {
const dataset = useDataset();
const { mutate } = useMutate();
const { mutate, error } = useMutate();
const [newName, setNewName] = useState("");
const [openSplits, setOpenSplits] = useState<string | null>(null);
const [newAmount, setNewAmount] = useState(`0 ${dataset.settings.baseCurrency}`);
const [destinationKind, setDestinationKind] = useState<"existing" | "dedicated">("dedicated");
const [newPostName, setNewPostName] = useState("");
const [newInterval, setNewInterval] = useState("1");
const [newUnit, setNewUnit] = useState<Recurrence["kind"]>("everyNMonths");
const [newWeekday, setNewWeekday] = useState(1);
const [newStartDate, setNewStartDate] = useState<IsoDate>(currentMonth);
const [newDatePrecision, setNewDatePrecision] = useState<"month" | "date">("month");
const [newAnchoring, setNewAnchoring] = useState<RecurringCost["anchoring"]>("calendar");
const [folderId, setFolderId] = useState<string | null>(null);
const chargedIds = new Set(dataset.purchases.flatMap(p => p.source ? [p.source.recurringId] : []));
const [newSelection, setNewSelection] = useState<RecurringSplitSelection>({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] });
const [editingId, setEditingId] = useState<string | null>(null);
const [endDatePrecision, setEndDatePrecision] = useState<Record<string, "month" | "date">>({});
const [editSelection, setEditSelection] = useState<RecurringSplitSelection | null>(null);
const ordered = [...dataset.recurring].sort((a, b) => a.order - b.order);
const base = dataset.settings.baseCurrency;
const firstPost = [...dataset.posts].sort((a, b) => a.order - b.order)[0];
const activePosts = dataset.posts
.filter((p) => !p.archived)
.sort((a, b) => a.order - b.order);
const postName = (postId: string) => dataset.posts.find((p) => p.id === postId)?.name;
function splitSummary(cost: RecurringCost) {
if (cost.splits.length === 1) {
return postName(cost.splits[0]!.postId) ?? "Choose a post…";
}
return `${cost.splits.length} posts`;
}
const activePosts = dataset.posts.filter(post => !post.archived).sort((a, b) => a.order - b.order);
const parsedNewAmount = parseMoneyInput(newAmount, dataset.currencies, base);
const validNewSplits = newSelection.splits.length > 0 && newSelection.splits.every(split => activePosts.some(post => post.id === split.postId));
const validNewInterval = Number.isInteger(Number(newInterval)) && Number(newInterval) >= 1;
const validNewStart = isValidStartDate(newStartDate, newUnit, newAnchoring);
function amountCell(cost: RecurringCost) {
return (
@@ -221,8 +199,8 @@ export function RecurringSection() {
? { kind, n: cost.recurrence.n, weekday: 1 }
: { kind, n: cost.recurrence.n };
// Switching to a day-granular kind against a month-only
// startDate is one of the ways this bricked the app: the walk
// hands "2026-09" to addDays and throws. Expand it to the first of
// startDate is exactly how C1 bricked the app: the walk hands
// "2026-09" to addDays and throws. Expand it to the first of
// that month IN THIS SAME mutate call, so the pairing the store
// now validates never has a chance to go wrong.
const startDate = normalizedStartDateFor(kind, cost.startDate, cost.anchoring);
@@ -265,6 +243,8 @@ export function RecurringSection() {
title="Recurring costs"
hint="Bills that repeat. Each one projects forward as EXPECTED spending and is confirmed from the month view when the money actually moves — expected never touches a post's real balance. Anchoring decides where the next charge is measured from: the calendar, or the date the last one actually charged. Use the second for a bill that can charge early, like a phone plan that recharges when its data runs out."
>
<p className="mb-3 text-xs"><a className="underline underline-offset-2" href="#budget-folders">Move and reorder bills in Budget folders</a></p>
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not save: {error}</p>}
{ordered.length === 0 ? (
<p className="text-sm text-budget-ink-muted">
No recurring costs yet. Add rent, a subscription, or a phone bill below.
@@ -280,18 +260,13 @@ export function RecurringSection() {
<th className="py-2 font-medium">Measured from</th>
<th className="py-2 font-medium">Starts</th>
<th className="py-2 font-medium">Ends</th>
<th className="py-2 font-medium">Split</th>
<th className="py-2" />
</tr>
</thead>
<tbody>
{ordered.map((cost, index) => {
const charged = dataset.purchases.some(
(p) => p.source?.recurringId === cost.id,
);
return (
<Fragment key={cost.id}>
{ordered.map((cost) => (<Fragment key={cost.id}>
<tr
data-bill-id={cost.id}
className={`border-b last:border-0 ${cost.archived ? "opacity-50" : ""}`}
>
<td className="py-2">
@@ -307,31 +282,25 @@ export function RecurringSection() {
<td className="py-2">{amountCell(cost)}</td>
<td className="py-2">{intervalCell(cost)}</td>
<td className="py-2">
{/* Locked once the cost has charges: the store refuses the
change (a calendar grid cannot be invented for charges
that settled no slot), so say so rather than fail. */}
<NativeSelect
className="h-8 w-auto text-xs md:text-xs"
aria-label={`Anchoring for ${cost.name}`}
value={cost.anchoring}
disabled={charged}
disabled={chargedIds.has(cost.id)}
title={
charged
chargedIds.has(cost.id)
? "This cost has charges recorded, so its anchoring cannot change. End it and create a new one."
: undefined
}
onChange={(event) => {
const anchoring = event.target.value as RecurringCost["anchoring"];
// Same normalisation the kind handler below does: an everyNMonths cost anchored to lastCharge
// needs a day-granular startDate, so expand a
// month-only one in the SAME mutate call that changes
// the anchoring — the store validates the pairing on
// every write and would otherwise refuse this one.
const startDate = normalizedStartDateFor(
cost.recurrence.kind,
cost.startDate,
anchoring,
);
mutate((draft) =>
updateRecurringCost(draft, cost.id, { anchoring, startDate }),
);
// Same mutate as the anchoring change: a month-only
// start must never meet lastCharge, even for a render.
const startDate = normalizedStartDateFor(cost.recurrence.kind, cost.startDate, anchoring);
mutate((draft) => updateRecurringCost(draft, cost.id, { anchoring, startDate }));
}}
>
<option value="calendar">the calendar</option>
@@ -339,6 +308,12 @@ export function RecurringSection() {
</NativeSelect>
</td>
<td className="py-2">
<div className="flex items-center gap-1">
{!startDateNeedsDay(cost.recurrence.kind, cost.anchoring) && <NativeSelect className="h-8 w-auto text-xs" aria-label={`Date precision for ${cost.name}`} value={isDayGranular(cost.startDate) ? "date" : "month"} onChange={event => {
const precision = event.target.value;
const startDate = precision === "date" ? `${monthOf(cost.startDate)}-01` : monthOf(cost.startDate);
void mutate(draft => updateRecurringCost(draft, cost.id, { startDate }));
}}><option value="month">Month</option><option value="date">Date</option></NativeSelect>}
{/* Uncontrolled: committing only happens on blur, and only
when the text parses as an IsoDate of the granularity
this cost's recurrence kind requires. Keyed on the
@@ -348,7 +323,7 @@ export function RecurringSection() {
<Input
key={`${cost.id}-${cost.startDate}`}
className="h-8 w-32"
type="text"
type={isDayGranular(cost.startDate) ? "date" : "month"}
aria-label={`Start date for ${cost.name}`}
defaultValue={cost.startDate}
onBlur={(event) => {
@@ -362,8 +337,11 @@ export function RecurringSection() {
);
}}
/>
</div>
</td>
<td className="py-2">
<div className="flex items-center gap-1">
<NativeSelect className="h-8 w-auto text-xs" aria-label={`End date precision for ${cost.name}`} value={endDatePrecision[cost.id] ?? (cost.endedFrom && isDayGranular(cost.endedFrom) ? "date" : "month")} onChange={event => setEndDatePrecision(precision => ({ ...precision, [cost.id]: event.target.value as "month" | "date" }))}><option value="month">Month</option><option value="date">Date</option></NativeSelect>
{/* Same idiom as "Starts": uncontrolled, committed on
blur, keyed on the stored value so an external change
(the end/restart button, or a rejected edit) shows up
@@ -371,12 +349,12 @@ export function RecurringSection() {
ended" and is itself a valid value — clearing it here
does the same thing as the "restart" button. */}
<Input
key={`${cost.id}-ends-${cost.endedFrom ?? ""}`}
key={`${cost.id}-ends-${cost.endedFrom ?? ""}-${endDatePrecision[cost.id] ?? "stored"}`}
className="h-8 w-32"
type="text"
type={endDatePrecision[cost.id] ?? (cost.endedFrom && isDayGranular(cost.endedFrom) ? "date" : "month")}
placeholder="—"
aria-label={`Ends date for ${cost.name}`}
defaultValue={cost.endedFrom ?? ""}
defaultValue={dateForInput(cost.endedFrom, endDatePrecision[cost.id] ?? (cost.endedFrom && isDayGranular(cost.endedFrom) ? "date" : "month"))}
onBlur={(event) => {
const typed = event.target.value.trim();
if (!isValidEndedFrom(typed)) {
@@ -392,42 +370,14 @@ export function RecurringSection() {
);
}}
/>
</td>
<td className="py-2">
{/* Same pattern PostsSection uses for rule history: a
size="xs" ghost button toggles an expandable row
holding the real editor, so the common case (one
post) stays a one-line summary. */}
<Button
type="button"
variant="ghost"
size="xs"
className="-mx-2 max-w-full font-normal underline decoration-dotted underline-offset-2"
aria-expanded={openSplits === cost.id}
aria-label={`Split for ${cost.name}`}
onClick={() => setOpenSplits((id) => (id === cost.id ? null : cost.id))}
>
{splitSummary(cost)}
</Button>
</div>
</td>
<td className="py-2">
<div className="flex items-center justify-end gap-1">
<Button
size="xs"
variant="ghost"
disabled={index === 0}
onClick={() => mutate((draft) => moveRecurringCost(draft, cost.id, -1))}
>
up
</Button>
<Button
size="xs"
variant="ghost"
disabled={index === ordered.length - 1}
onClick={() => mutate((draft) => moveRecurringCost(draft, cost.id, 1))}
>
down
</Button>
<Button id={`bill-${cost.id}`} size="xs" variant="ghost" onClick={() => {
setEditingId(cost.id);
setEditSelection({ splitMode: cost.splitMode, splits: cost.splits.map(split => ({ ...split })) });
}}>Edit</Button>
<Button
size="xs"
variant="ghost"
@@ -445,24 +395,21 @@ export function RecurringSection() {
</div>
</td>
</tr>
{openSplits === cost.id && (
<tr className="border-b last:border-0">
<td colSpan={8} className="pb-3">
<SplitEditor
draft={splitTargetOf(cost)}
posts={activePosts}
onChange={(next) =>
mutate((draft) =>
updateRecurringCost(draft, cost.id, splitChangesOf(next)),
)
}
/>
</td>
</tr>
{editingId === cost.id && editSelection && (
<tr key={`${cost.id}-editor`}><td colSpan={7} className="py-3">
<div className="space-y-3 rounded-md border border-budget-rule p-3">
<RecurringSplitEditor {...editSelection} posts={activePosts} onChange={setEditSelection} />
<div className="flex gap-2">
<Button size="sm" disabled={!editSelection.splits.every(split => activePosts.some(post => post.id === split.postId))} onClick={() => {
const selected = { splitMode: editSelection.splitMode, splits: editSelection.splits.map(split => ({ ...split })) };
void mutate(draft => updateRecurringCost(draft, cost.id, selected)).then(saved => { if (saved) setEditingId(null); });
}}>Save destinations</Button>
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>Cancel</Button>
</div>
</div>
</td></tr>
)}
</Fragment>
);
})}
</Fragment>))}
</tbody>
</table>
</div>
@@ -482,34 +429,103 @@ export function RecurringSection() {
className="w-48"
placeholder="New cost name"
value={newName}
onChange={(event) => setNewName(event.target.value)}
onInput={(event) => setNewName(event.currentTarget.value)}
/>
<Input className="w-32" type="text" aria-label="New bill amount" value={newAmount} onInput={event => setNewAmount(event.currentTarget.value)} />
<label className="text-xs">Every
<span className="mt-1 flex items-center gap-1">
<Input className="w-20" type="number" min={1} step={1} aria-label="New bill interval" value={newInterval} onInput={event => setNewInterval(event.currentTarget.value)} />
<NativeSelect aria-label="New bill recurrence unit" value={newUnit} onChange={event => {
const kind = event.target.value as Recurrence["kind"];
setNewUnit(kind);
setNewStartDate(date => normalizedStartDateFor(kind, date, newAnchoring));
if (startDateNeedsDay(kind, newAnchoring)) setNewDatePrecision("date");
}}>
<option value="everyNMonths">months</option>
<option value="everyNWeeks">weeks</option>
<option value="everyNDays">days</option>
</NativeSelect>
</span>
</label>
{newUnit === "everyNWeeks" && <NativeSelect aria-label="New bill weekday" value={newWeekday} onChange={event => setNewWeekday(Number(event.target.value))}>
{WEEKDAYS.map((day, index) => <option key={day} value={index}>{day}</option>)}
</NativeSelect>}
<label className="text-xs">First due
<span className="mt-1 flex items-center gap-1">
{!startDateNeedsDay(newUnit, newAnchoring) && <NativeSelect aria-label="New bill date precision" value={newDatePrecision} onChange={event => {
const precision = event.target.value as "month" | "date";
setNewDatePrecision(precision);
setNewStartDate(date => {
const valid = isValidStartDate(date, "everyNMonths", "calendar") ? date : currentMonth;
return precision === "date" ? normalizedStartDateFor("everyNDays", valid, "calendar") : monthOf(valid);
});
}}><option value="month">Month</option><option value="date">Date</option></NativeSelect>}
<Input className="w-36" type={!startDateNeedsDay(newUnit, newAnchoring) && newDatePrecision === "month" ? "month" : "date"} aria-label="New bill start date" value={newStartDate} onChange={event => setNewStartDate(event.target.value)} aria-invalid={!validNewStart} />
</span>
</label>
<NativeSelect aria-label="New bill anchoring" value={newAnchoring} onChange={event => {
const anchoring = event.target.value as RecurringCost["anchoring"];
setNewAnchoring(anchoring);
setNewStartDate(date => normalizedStartDateFor(newUnit, date, anchoring));
if (startDateNeedsDay(newUnit, anchoring)) setNewDatePrecision("date");
}}>
<option value="calendar">From calendar</option>
<option value="lastCharge">From last charge</option>
</NativeSelect>
<NativeSelect aria-label="Bill folder" value={folderId ?? ""} onChange={event => setFolderId(event.target.value || null)}>
<option value="">Unfiled</option>
{dataset.folders.map(folder => <option key={folder.id} value={folder.id}>{folder.name}</option>)}
</NativeSelect>
<NativeSelect aria-label="Destination type" value={destinationKind} onChange={event => setDestinationKind(event.target.value as "existing" | "dedicated")}>
<option value="dedicated">Create dedicated post</option>
<option value="existing">Use existing post</option>
</NativeSelect>
{destinationKind === "dedicated" ? (
<Input aria-label="Dedicated post name" placeholder={newName.trim() || "Same name as bill"} value={newPostName} onChange={event => setNewPostName(event.target.value)} />
) : (
<RecurringSplitEditor {...newSelection} posts={activePosts} onChange={setNewSelection} />
)}
<Button
variant="outline"
disabled={newName.trim() === "" || !firstPost}
disabled={newName.trim() === "" || !parsedNewAmount || !validNewInterval || !validNewStart || (destinationKind === "existing" && !validNewSplits)}
onClick={() => {
const name = newName.trim();
mutate((draft) =>
addRecurringCost(draft, {
const amount = parsedNewAmount;
if (!amount) return;
const selectedFolder = folderId;
const selectedKind = destinationKind;
const dedicatedName = newPostName.trim() || name;
const startDate = newStartDate;
const n = Number(newInterval);
const recurrence: Recurrence = newUnit === "everyNWeeks" ? { kind: newUnit, n, weekday: newWeekday } : { kind: newUnit, n };
const anchoring = newAnchoring;
const selectedSplits = newSelection.splits.map(split => ({ ...split }));
const selectedMode = newSelection.splitMode;
void mutate((draft) => {
const input = {
name,
folderId: selectedFolder,
archived: false,
amount: { amount: 0, currency: base },
startDate: currentMonth,
recurrence: { kind: "everyNMonths", n: 1 },
anchoring: "calendar",
splitMode: "percent",
splits: [{ postId: firstPost!.id, value: 100, absorbsRemainder: true }],
}),
);
setNewName("");
amount,
startDate,
recurrence,
anchoring,
};
if (selectedKind === "dedicated") addRecurringCostWithPost(draft, input, dedicatedName);
else {
const bill = addRecurringCost(draft, { ...input, splitMode: selectedMode, splits: selectedSplits });
if (selectedFolder === null && selectedSplits.length === 1) placeRecurringUnderPost(draft, bill.id, selectedSplits[0]!.postId);
}
},
).then((saved) => {
if (saved) { setNewName(""); setNewPostName(""); setNewAmount(`0 ${base}`); setNewSelection({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] }); }
});
}}
>
Add recurring cost
</Button>
{!firstPost && (
<span className="text-xs text-budget-ink-muted">Create a post first.</span>
)}
</div>
<p className="mt-2 text-xs text-budget-ink-muted">A dedicated post receives an automatic monthly allocation based on this bill’s schedule. If its first payment comes before enough has accumulated, its balance may be negative until later allocations catch up.</p>
</Section>
);
}