docs: plan budget folders and recurring cost placement

This commit is contained in:
Lucas Winther committed 2026-09-29 20:18:25 +02:00
1 parent f1e42d9d77
commit 67c77eb247
1 file changed
+143
@@ -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.