feat(balance): reducer holdings + dispatch account.kind (#213) #222

Merged
maximus merged 1 commit from issue-213-reducer-dispatch into main 2026-06-10 01:07:52 +00:00
Owner

Resolves #213

Stacked on #212 (base branch issue-212-service-securities).

What this is

The state/plumbing layer of the per-title detail feature: a structural rewrite of useSnapshotEditor to hold N holdings per detailed account, and a switch of all simple/detailed dispatch from category_kind to the account's own kind. The full multi-security entry UI (SecurityPicker, rich sub-rows) is the next issue (#214). Simple accounts behave identically.

Reducer state shape (for #214/#215 downstream)

  • values: Record<accountId, string> — simple accounts (scalar, unchanged).
  • holdings: Record<accountId, HoldingDraft[]> — detailed accounts, one draft per title. HoldingDraft is a string-typed editable mirror of SnapshotHoldingInput (symbol, asset_type, currency, security_name, quantity, unit_price, book_cost, price_source/at) plus a stable client rowId for React keys.
  • New actions: ADD_HOLDING / REMOVE_HOLDING / SET_HOLDING_FIELD (+ existing SET_VALUE/PREFILL/RESET). The dead pricedValues/SET_PRICED_FIELD scalar-priced path is removed — post-#211 no account is simple-category-priced anymore.

Holdings flow to save

buildDetailedLines produces one SnapshotLineInput per detailed account carrying its holdings array (presence marks the line detailed; aggregated value = rounded-cent SUM). buildSimpleLines produces scalar lines for non-detailed accounts. Blank holding rows are skipped; a partially-filled row throws a typed error before any DB write. Both feed the existing saveSnapshotAtomic.

Dispatch on account.kind

  • LOADED: edit-mode hydrates baskets via listHoldingsBySnapshotLine (keeps saved price); new-mode prefills via getHoldingsForLatestSnapshot (drops price, qty-0 excluded server-side, book_cost carried).
  • SnapshotLineRow / SnapshotEditor / AccountForm gate on account.kind. category.kind survives only as the suggested seed default for a NEW account.

Notable decisions

  • AccountForm kind selector previews the suggested mode + drives gating, but a NEW account still persists as simple (CreateBalanceAccountInput has no kind; balance.service.ts is out of this issue's scope). Create-as-detailed + pivot date is #215. On edit, kind is forwarded only on a simple→detailed change; the selector is locked for an already-detailed account (the detailed→simple downgrade is service-guarded).
  • Detailed UI is intentionally minimal here (round-trips converted 1-holding accounts, add/remove rows, per-row price fetch, live sum) — the symbol autocomplete picker is #214.
  • CHANGELOG deferred to #218 (owns the consolidated [Unreleased] entry; the whole #210–#212 chain added none).

Gate

npm run build · npm test (603 passed, 19 new reducer/helper tests).

Generated autonomously by /autopilot run of 2026-06-06

Resolves #213 Stacked on #212 (base branch `issue-212-service-securities`). ## What this is The state/plumbing layer of the per-title detail feature: a structural rewrite of `useSnapshotEditor` to hold N holdings per detailed account, and a switch of **all** simple/detailed dispatch from `category_kind` to the account's own `kind`. The full multi-security entry UI (SecurityPicker, rich sub-rows) is the next issue (#214). Simple accounts behave identically. ## Reducer state shape (for #214/#215 downstream) - `values: Record<accountId, string>` — simple accounts (scalar, unchanged). - `holdings: Record<accountId, HoldingDraft[]>` — detailed accounts, one draft per title. `HoldingDraft` is a string-typed editable mirror of `SnapshotHoldingInput` (symbol, asset_type, currency, security_name, quantity, unit_price, book_cost, price_source/at) plus a stable client `rowId` for React keys. - New actions: `ADD_HOLDING` / `REMOVE_HOLDING` / `SET_HOLDING_FIELD` (+ existing `SET_VALUE`/`PREFILL`/`RESET`). The dead `pricedValues`/`SET_PRICED_FIELD` scalar-priced path is removed — post-#211 no account is simple-category-priced anymore. ## Holdings flow to save `buildDetailedLines` produces one `SnapshotLineInput` per detailed account carrying its `holdings` array (presence marks the line detailed; aggregated `value` = rounded-cent SUM). `buildSimpleLines` produces scalar lines for non-detailed accounts. Blank holding rows are skipped; a partially-filled row throws a typed error before any DB write. Both feed the existing `saveSnapshotAtomic`. ## Dispatch on account.kind - `LOADED`: edit-mode hydrates baskets via `listHoldingsBySnapshotLine` (keeps saved price); new-mode prefills via `getHoldingsForLatestSnapshot` (drops price, qty-0 excluded server-side, book_cost carried). - `SnapshotLineRow` / `SnapshotEditor` / `AccountForm` gate on `account.kind`. `category.kind` survives only as the suggested seed default for a NEW account. ## Notable decisions - **AccountForm kind selector** previews the suggested mode + drives gating, but a NEW account still persists as `simple` (`CreateBalanceAccountInput` has no `kind`; `balance.service.ts` is out of this issue's scope). Create-as-detailed + pivot date is **#215**. On edit, `kind` is forwarded only on a simple→detailed change; the selector is locked for an already-detailed account (the detailed→simple downgrade is service-guarded). - Detailed UI is intentionally minimal here (round-trips converted 1-holding accounts, add/remove rows, per-row price fetch, live sum) — the symbol autocomplete picker is **#214**. - CHANGELOG deferred to **#218** (owns the consolidated `[Unreleased]` entry; the whole #210–#212 chain added none). ## Gate `npm run build` ✅ · `npm test` ✅ (603 passed, 19 new reducer/helper tests). Generated autonomously by /autopilot run of 2026-06-06
maximus added the
status:review
autopilot:pending-human
labels 2026-06-06 17:30:14 +00:00
maximus added 1 commit 2026-06-06 17:30:14 +00:00
Structural rewrite of useSnapshotEditor for N holdings per detailed account,
and switch ALL simple/detailed dispatch from category_kind to the account's
own kind. This is the state/plumbing layer; the full multi-security entry UI
(SecurityPicker, rich sub-rows) lands in #214. Simple accounts behave
identically.

Reducer state shape:
- values:   Record<accountId, string>        (simple accounts, scalar)
- holdings: Record<accountId, HoldingDraft[]> (detailed accounts, one per title)
HoldingDraft is a string-typed, editable mirror of SnapshotHoldingInput with a
stable client-side rowId for React keys. Actions: ADD_HOLDING / REMOVE_HOLDING /
SET_HOLDING_FIELD (plus existing SET_VALUE/PREFILL/RESET). The legacy priced
scalar path (SET_PRICED_FIELD / pricedValues) is removed: after migration v16
(#211) every former-priced account is kind='detailed' with one holding, so those
accounts flow through the holdings path.

Dispatch:
- LOADED hydrates detailed baskets via listHoldingsBySnapshotLine (edit) keeping
  the saved price, or getHoldingsForLatestSnapshot (new) dropping the price
  (qty-0 excluded server-side). Simple accounts keep the scalar value path.
- SnapshotLineRow / SnapshotEditor / AccountForm now gate on account.kind, not
  category_kind. category.kind survives ONLY as the suggested seed default for a
  NEW account in AccountForm.

Save: detailed accounts pass their holdings array into SnapshotLineInput.holdings
(presence marks the line detailed; value = rounded-cent SUM); simple accounts
pass a scalar value with no holdings. Blank holding rows are skipped; a partial
row throws a typed error before any DB mutation.

AccountForm: adds an entry-mode selector (defaulting to the category-mapped
kind). New accounts persist as 'simple' (CreateBalanceAccountInput carries no
kind, and the service is out of this issue's scope); converting a fresh account
to detailed + pivot date is #215. Editing locks the selector for an already-
detailed account (the detailed->simple downgrade is service-guarded).

Tests: 19 new reducer/helper unit tests (pure exports; the project has no
renderHook harness) covering ADD/REMOVE/SET_HOLDING_FIELD, LOADED-vs-PREFILL
hydration (price drop, book_cost), qty-0 already excluded upstream, the
build*Lines save builders, and the dispatch-on-account.kind regression
(detailed account under a 'simple' category).

Resolves #213.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

Adversarial review — PR #222 (Issue #213) — APPROVE

Link 4/9 (reducer rewrite). Reviewed the full diff (1083+/363−, 8 files) against the base branch issue-212-service-securities and the actual service/types contracts. This is a clean, complete migration.

Dispatch migration — complete

Every dispatch site in the touched files now keys on the account's OWN kind:

  • SnapshotLineRow L78 account.kind === "detailed"
  • useSnapshotEditor LOAD (L539 edit / L560 new), save (L692 detailedAccountIds), prefillFromPrevious (L658)
  • AccountForm (AccountVariant) L157 values.kind === "detailed" (was isPriced)

The remaining values.kind === "priced" reads in AccountForm.tsx (L423–539) belong to the separate CategoryVariant using CategoryFormValues.kind: BalanceCategoryKind — category kind, correctly left as priced. Not an account-dispatch leftover. No residual category_kind dispatch anywhere.

Dead-path removal — safe

PricedEntry / SET_PRICED_FIELD / pricedValues / setLineQuantity / setLineUnitPrice / onQuantityChange / onUnitPriceChange are fully removed. Grepped all 239 src TS/TSX files (only SnapshotEditPageSnapshotEditorSnapshotLineRow consume these) — zero dangling references. The simple scalar path in SnapshotLineRow is unchanged (// Simple variant — unchanged from #146).

Reducer correctness

  • ADD = spread-append, REMOVE = filter, SET_HOLDING_FIELD = map with field spread — all immutable, other baskets untouched.
  • LOADED hydrates via listHoldingsBySnapshotLine (keepPrice:true); new-mode prefills via getHoldingsForLatestSnapshot (keepPrice:false, drops price+source, keeps qty+book_cost).
  • Stable rowId via monotonic nextRowId() (h{n}), distinct per row (tested).
  • Detailed account with no line at this snapshot still gets an empty basket so the detailed variant renders.

Save wiring — matches service contract exactly

  • buildDetailedLines ALWAYS emits holdings (even []), which is precisely how the service's isDetailedLine (line.holdings !== undefined) classifies detailed — confirmed against validateAllLines / insertSnapshotLineWithHoldings. buildSimpleLines emits account_kind:"simple", never holdings.
  • Dispatch is on account.kind (set built from state.accounts), so a detailed account under a simple category routes through holdings and a stray scalar values[id] is excluded (tested).
  • Blank rows skipped; partial row (symbol w/o qty, or qty/price unparseable) throws a typed BalanceServiceError before any DB write (#176 invariant preserved).
  • Rounding is consistent: builder round(qty*price*100)/100 per holding then round(sum*100)/100 per line; service roundToCent per holding summed and compared EXACTLY to roundToCent(line.value) — equality holds.

Prefill

qty-0 exclusion (h.quantity <> 0), book_cost carry, and sold-then-rebought ("latest non-zero wins") are all in getHoldingsForLatestSnapshot's SQL; holdingsFromServiceHoldings maps book_cost through (NULL → "", tested). state.accounts correctly added to save's dep array.

Create-as-detailed scoping — reasonable, not misleading

The kind selector at CREATION is preview-only: the CREATE payload is typed CreateBalanceAccountInput which has no kind field (TS-enforced — can't accidentally persist it), so a new account is simple. The detailedCreateHint ("New accounts start as a single amount; convert one to by-title from the accounts list") renders exactly when !isEditing && kind==='detailed', so the UX is honest. The convert path it promises works today: editing a simple account from AccountsPage and switching the selector forwards updatePayload.kindeditAccountupdateBalanceAccount (applies kind, guards detailed→simple via account_kind_detailed_has_holdings). Only the pivot-date wizard (detailed_since) is deferred to #215 — a refinement, not a broken path. Selector correctly locked for already-detailed accounts.

Tests

19 it() / 5 describe, no .only/.skip, import the real reducer/buildSimpleLines/buildDetailedLines/holdingsFromServiceHoldings (only services/db mocked). They substantively prove: holdings-action immutability, RESET/PREFILL merge, LOADED-vs-PREFILL price handling, the SUM/rounding (20.01+30.10→50.11), blank-skip + partial-throw, and the dispatch-on-account.kind regression (detailed under simple category → holdings path, stray scalar ignored).

i18n

New keys balance.account.form.kind.* and balance.snapshot.detailed.* present in both fr.json and en.json; no hardcoded user-facing strings in the touched components.

Non-blocking nits (no fix required)

  • SET_HOLDING_FIELD's field: keyof Omit<HoldingDraft,"rowId"> with value: string is slightly loose — it would type-allow setting asset_type/price_source (non-string fields) to a string. No caller does (only symbol/quantity/unit_price are wired), so no runtime impact. Could narrow to the 3 string fields in a later pass.
  • HoldingSubRow's onPriceFetched={(price) => …} ignores the currency second arg (CAD-only MVP) — same as the pre-existing priced row; fine.
  • CHANGELOG intentionally deferred to #218 per the stacked-chain convention.

No must-fix issues. APPROVE.

— Adversarial review, autopilot

## Adversarial review — PR #222 (Issue #213) — **APPROVE** ✅ Link 4/9 (reducer rewrite). Reviewed the full diff (1083+/363−, 8 files) against the base branch `issue-212-service-securities` and the actual service/types contracts. This is a clean, complete migration. ### Dispatch migration — complete ✅ Every dispatch site in the touched files now keys on the account's OWN `kind`: - `SnapshotLineRow` L78 `account.kind === "detailed"` - `useSnapshotEditor` LOAD (L539 edit / L560 new), `save` (L692 `detailedAccountIds`), `prefillFromPrevious` (L658) - `AccountForm` (AccountVariant) L157 `values.kind === "detailed"` (was `isPriced`) The remaining `values.kind === "priced"` reads in `AccountForm.tsx` (L423–539) belong to the **separate `CategoryVariant`** using `CategoryFormValues.kind: BalanceCategoryKind` — category kind, correctly left as `priced`. Not an account-dispatch leftover. No residual `category_kind` dispatch anywhere. ### Dead-path removal — safe ✅ `PricedEntry` / `SET_PRICED_FIELD` / `pricedValues` / `setLineQuantity` / `setLineUnitPrice` / `onQuantityChange` / `onUnitPriceChange` are fully removed. Grepped all 239 src TS/TSX files (only `SnapshotEditPage` → `SnapshotEditor` → `SnapshotLineRow` consume these) — **zero dangling references**. The simple scalar path in `SnapshotLineRow` is unchanged (`// Simple variant — unchanged from #146`). ### Reducer correctness ✅ - ADD = spread-append, REMOVE = `filter`, SET_HOLDING_FIELD = `map` with field spread — all immutable, other baskets untouched. - LOADED hydrates via `listHoldingsBySnapshotLine` (`keepPrice:true`); new-mode prefills via `getHoldingsForLatestSnapshot` (`keepPrice:false`, drops price+source, keeps qty+book_cost). - Stable `rowId` via monotonic `nextRowId()` (`h{n}`), distinct per row (tested). - Detailed account with no line at this snapshot still gets an empty basket so the detailed variant renders. ### Save wiring — matches service contract exactly ✅ - `buildDetailedLines` ALWAYS emits `holdings` (even `[]`), which is precisely how the service's `isDetailedLine` (`line.holdings !== undefined`) classifies detailed — confirmed against `validateAllLines` / `insertSnapshotLineWithHoldings`. `buildSimpleLines` emits `account_kind:"simple"`, never `holdings`. - Dispatch is on `account.kind` (set built from `state.accounts`), so a detailed account under a *simple* category routes through holdings and a stray scalar `values[id]` is excluded (tested). - Blank rows skipped; partial row (symbol w/o qty, or qty/price unparseable) throws a typed `BalanceServiceError` **before** any DB write (#176 invariant preserved). - Rounding is consistent: builder `round(qty*price*100)/100` per holding then `round(sum*100)/100` per line; service `roundToCent` per holding summed and compared EXACTLY to `roundToCent(line.value)` — equality holds. ### Prefill ✅ qty-0 exclusion (`h.quantity <> 0`), book_cost carry, and sold-then-rebought ("latest non-zero wins") are all in `getHoldingsForLatestSnapshot`'s SQL; `holdingsFromServiceHoldings` maps book_cost through (NULL → `""`, tested). `state.accounts` correctly added to `save`'s dep array. ### Create-as-detailed scoping — reasonable, not misleading ✅ The kind selector at CREATION is preview-only: the CREATE `payload` is typed `CreateBalanceAccountInput` which has **no** `kind` field (TS-enforced — can't accidentally persist it), so a new account is `simple`. The `detailedCreateHint` ("New accounts start as a single amount; convert one to by-title from the accounts list") renders exactly when `!isEditing && kind==='detailed'`, so the UX is honest. **The convert path it promises works today**: editing a `simple` account from `AccountsPage` and switching the selector forwards `updatePayload.kind` → `editAccount` → `updateBalanceAccount` (applies `kind`, guards detailed→simple via `account_kind_detailed_has_holdings`). Only the pivot-date wizard (`detailed_since`) is deferred to #215 — a refinement, not a broken path. Selector correctly locked for already-detailed accounts. ### Tests ✅ 19 `it()` / 5 `describe`, **no `.only`/`.skip`**, import the real `reducer`/`buildSimpleLines`/`buildDetailedLines`/`holdingsFromServiceHoldings` (only `services/db` mocked). They substantively prove: holdings-action immutability, RESET/PREFILL merge, LOADED-vs-PREFILL price handling, the SUM/rounding (20.01+30.10→50.11), blank-skip + partial-throw, and the **dispatch-on-account.kind regression** (detailed under simple category → holdings path, stray scalar ignored). ### i18n ✅ New keys `balance.account.form.kind.*` and `balance.snapshot.detailed.*` present in **both** `fr.json` and `en.json`; no hardcoded user-facing strings in the touched components. ### Non-blocking nits (no fix required) - `SET_HOLDING_FIELD`'s `field: keyof Omit<HoldingDraft,"rowId">` with `value: string` is slightly loose — it would *type-allow* setting `asset_type`/`price_source` (non-string fields) to a string. No caller does (only symbol/quantity/unit_price are wired), so no runtime impact. Could narrow to the 3 string fields in a later pass. - `HoldingSubRow`'s `onPriceFetched={(price) => …}` ignores the `currency` second arg (CAD-only MVP) — same as the pre-existing priced row; fine. - CHANGELOG intentionally deferred to #218 per the stacked-chain convention. **No must-fix issues.** APPROVE. — Adversarial review, autopilot
maximus changed target branch from issue-212-service-securities to main 2026-06-10 01:07:50 +00:00
maximus merged commit cbaa9cb6d0 into main 2026-06-10 01:07:52 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#222
No description provided.