// useSnapshotEditor — scoped useReducer hook backing SnapshotEditPage. // // Lifecycle of a single snapshot (Issue #146 / Bilan #1b; reworked for // per-title detail in Issue #213 / Bilan détail par titre): // 1. mount in 'new' mode (no `?date=` query param) → user picks a date, // types values, hits Save → service.saveSnapshotAtomic; // 2. mount in 'edit' mode (`?date=YYYY-MM-DD`) → load snapshot + lines, // user edits values, hits Save → upsert on the existing snapshot; // 3. delete → service.deleteSnapshot (the page wraps this in a // double-confirm modal that requires retyping the snapshot date). // // ENTRY MODE DISPATCH (#213) — the editor classifies each account by its OWN // `account.kind` (simple | detailed), NOT by `category_kind` (simple | priced). // The category kind is only a *suggested default* for a brand-new account in // AccountForm; once an account exists, its stored `kind` is authoritative. // - simple : one scalar value per account, kept as a string in `values`. // - detailed: a basket of per-security holdings in `holdings` — one // `HoldingDraft` per title. On save the aggregated line carries // NO scalar qty/price; the service recomputes value = SUM(holdings) // and writes the holdings in the same transaction. // // The legacy "priced scalar" path (one security per account via account.symbol // + scalar quantity/unit_price on the line) is SUPERSEDED: after migration v16 // (#211) every former-priced account is now `kind='detailed'` with one holding, // so those accounts flow through the holdings path. There is no scalar-priced // editor branch anymore. import { useReducer, useCallback, useEffect, useRef, } from "react"; import type { BalanceAccountWithCategory, BalanceAssetType, BalanceCategory, BalanceSnapshot, BalanceSnapshotLine, BalanceSnapshotHoldingWithSecurity, } from "../shared/types"; import { listBalanceAccounts, listBalanceCategories, getSnapshotByDate, deleteSnapshot, listLinesBySnapshot, saveSnapshotAtomic, getPreviousSnapshot, getHoldingsForLatestSnapshot, listHoldingsBySnapshotLine, BalanceServiceError, type SnapshotLineInput, type SnapshotHoldingInput, } from "../services/balance.service"; export type SnapshotEditorMode = "new" | "edit"; /** * String-typed, editable mirror of one position inside a detailed account * (Issue #213). All numeric fields are kept as strings to preserve empty / * partial input; conversion to numbers happens at save time. `rowId` is a * stable client-side identity so React can key the sub-rows even before the * holding is persisted (a fresh holding has no DB id yet). */ export interface HoldingDraft { /** Stable client-side row identity for React keys (NOT a DB id). */ rowId: string; /** Security symbol (normalized server-side). */ symbol: string; asset_type: BalanceAssetType; /** ISO 4217; defaults to 'CAD'. */ currency: string; /** Optional human-readable security name. */ security_name: string; quantity: string; unit_price: string; /** Acquisition cost basis for the unrealized-gain column; optional. */ book_cost: string; /** Carried through from a fetched price so save can attribute the source. */ price_source: string | null; price_fetched_at: string | null; } let holdingRowSeq = 0; /** Monotonic client-side row id for holding drafts. */ function nextRowId(): string { holdingRowSeq += 1; return `h${holdingRowSeq}`; } /** * Build an empty holding draft (used by ADD_HOLDING). `asset_type` defaults to * the account's category asset_type when known, else 'stock'. */ export function makeEmptyHolding( assetType: BalanceAssetType = "stock" ): HoldingDraft { return { rowId: nextRowId(), symbol: "", asset_type: assetType, currency: "CAD", security_name: "", quantity: "", unit_price: "", book_cost: "", price_source: null, price_fetched_at: null, }; } /** * Map server holdings (from `getHoldingsForLatestSnapshot` for prefill, or * `listHoldingsBySnapshotLine` for an edited snapshot) into editable string * drafts. `keepPrice` controls whether the unit_price is carried over: * - LOADED (editing an existing snapshot) keeps the saved price. * - PREFILL of the NEXT snapshot drops the price (the user re-enters or * re-fetches it) but keeps quantity + book_cost. Titles with quantity 0 are * already excluded by `getHoldingsForLatestSnapshot`; a title sold then * re-bought reappears because its latest non-zero holding wins server-side. */ export function holdingsFromServiceHoldings( rows: BalanceSnapshotHoldingWithSecurity[], opts: { keepPrice: boolean } ): HoldingDraft[] { return rows.map((h) => ({ rowId: nextRowId(), symbol: h.security_symbol, asset_type: h.security_asset_type, // The joined holdings view doesn't carry the security currency; CAD is the // MVP currency and the save path defaults it server-side anyway. currency: "CAD", security_name: h.security_name ?? "", quantity: h.quantity !== null && h.quantity !== undefined ? String(h.quantity) : "", unit_price: opts.keepPrice ? h.unit_price !== null && h.unit_price !== undefined ? String(h.unit_price) : "" : "", book_cost: h.book_cost !== null && h.book_cost !== undefined ? String(h.book_cost) : "", // Prefill drops the source (the carried price is stale); LOADED keeps it. price_source: opts.keepPrice ? h.price_source : null, price_fetched_at: opts.keepPrice ? h.price_fetched_at : null, })); } interface State { mode: SnapshotEditorMode; /** ISO YYYY-MM-DD; editable in both modes (a change in 'edit' moves the snapshot). */ snapshotDate: string; /** Current snapshot row in 'edit' mode (has the id needed for upsert). */ snapshot: BalanceSnapshot | null; /** All active accounts (with category metadata) — drives the line list. */ accounts: BalanceAccountWithCategory[]; /** Used to group lines by category in the editor view. */ categories: BalanceCategory[]; /** * Map of account_id → string-typed value (simple accounts only). We keep * strings to preserve empty / partial input; conversion to number happens * at save time. */ values: Record; /** * Map of account_id → array of `HoldingDraft` (detailed accounts only — * dispatched on `account.kind === 'detailed'`). One entry per security held. * Same partial-input guarantee as `values`. */ holdings: Record; /** Snapshot whose values would prefill if the user clicks "Prefill". */ previousSnapshot: BalanceSnapshot | null; /** Lines from `previousSnapshot` (loaded lazily when needed). */ previousLines: BalanceSnapshotLine[] | null; isLoading: boolean; isSaving: boolean; isDirty: boolean; error: string | null; errorCode: string | null; } type Action = | { type: "SET_LOADING"; payload: boolean } | { type: "SET_SAVING"; payload: boolean } | { type: "SET_ERROR"; payload: { message: string | null; code: string | null } } | { type: "LOADED"; payload: { mode: SnapshotEditorMode; snapshotDate: string; snapshot: BalanceSnapshot | null; accounts: BalanceAccountWithCategory[]; categories: BalanceCategory[]; values: Record; holdings: Record; previousSnapshot: BalanceSnapshot | null; previousLines: BalanceSnapshotLine[] | null; }; } | { type: "SET_DATE"; payload: string } | { type: "SET_VALUE"; payload: { accountId: number; value: string } } | { type: "ADD_HOLDING"; payload: { accountId: number; holding: HoldingDraft }; } | { type: "REMOVE_HOLDING"; payload: { accountId: number; rowId: string } } | { type: "SET_HOLDING_FIELD"; payload: { accountId: number; rowId: string; field: keyof Omit; value: string; }; } | { type: "PREFILL"; payload: { values: Record; holdings: Record; }; } | { type: "RESET" } | { type: "CLEAR_DIRTY" }; export function initialState(initialDate: string): State { return { mode: "new", snapshotDate: initialDate, snapshot: null, accounts: [], categories: [], values: {}, holdings: {}, previousSnapshot: null, previousLines: null, isLoading: false, isSaving: false, isDirty: false, error: null, errorCode: null, }; } /** * Pure reducer — exported so the editor's state machine can be unit-tested * without rendering the hook (the project has no jsdom/renderHook harness). */ export function reducer(state: State, action: Action): State { switch (action.type) { case "SET_LOADING": return { ...state, isLoading: action.payload }; case "SET_SAVING": return { ...state, isSaving: action.payload }; case "SET_ERROR": return { ...state, error: action.payload.message, errorCode: action.payload.code, isLoading: false, isSaving: false, }; case "LOADED": return { ...state, mode: action.payload.mode, snapshotDate: action.payload.snapshotDate, snapshot: action.payload.snapshot, accounts: action.payload.accounts, categories: action.payload.categories, values: action.payload.values, holdings: action.payload.holdings, previousSnapshot: action.payload.previousSnapshot, previousLines: action.payload.previousLines, isLoading: false, isDirty: false, error: null, errorCode: null, }; case "SET_DATE": // Editable in both modes now (#200): in 'edit' mode a changed date // triggers a snapshot move on save (lines preserved). return { ...state, snapshotDate: action.payload, isDirty: true }; case "SET_VALUE": return { ...state, values: { ...state.values, [action.payload.accountId]: action.payload.value, }, isDirty: true, }; case "ADD_HOLDING": { const existing = state.holdings[action.payload.accountId] ?? []; return { ...state, holdings: { ...state.holdings, [action.payload.accountId]: [...existing, action.payload.holding], }, isDirty: true, }; } case "REMOVE_HOLDING": { const existing = state.holdings[action.payload.accountId] ?? []; return { ...state, holdings: { ...state.holdings, [action.payload.accountId]: existing.filter( (h) => h.rowId !== action.payload.rowId ), }, isDirty: true, }; } case "SET_HOLDING_FIELD": { const existing = state.holdings[action.payload.accountId] ?? []; const next = existing.map((h) => h.rowId === action.payload.rowId ? { ...h, [action.payload.field]: action.payload.value } : h ); return { ...state, holdings: { ...state.holdings, [action.payload.accountId]: next, }, isDirty: true, }; } case "PREFILL": return { ...state, values: { ...state.values, ...action.payload.values }, holdings: { ...state.holdings, ...action.payload.holdings }, isDirty: true, }; case "RESET": return { ...state, // Keep the loaded structure (accounts, categories, snapshot) but wipe // user input back to a clean slate. values: {}, holdings: {}, isDirty: true, }; case "CLEAR_DIRTY": return { ...state, isDirty: false }; default: return state; } } function describeError(e: unknown): { message: string; code: string | null } { if (e instanceof BalanceServiceError) { return { message: e.message, code: e.code }; } return { message: e instanceof Error ? e.message : String(e), code: null, }; } function todayISO(): string { // Avoid timezone drift: use local YYYY-MM-DD, not toISOString() which is UTC. const d = new Date(); const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); return `${yyyy}-${mm}-${dd}`; } /** Parse "12.34" / "12,34" → finite number, or null when empty/invalid. */ function parseDecimal(raw: string | null | undefined): number | null { if (raw === null || raw === undefined) return null; const trimmed = String(raw).trim().replace(",", "."); if (!trimmed) return null; const n = Number(trimmed); return Number.isFinite(n) ? n : null; } /** * Build the simple-account `SnapshotLineInput[]` from the editor's `values` * map. Only accounts whose own kind is NOT detailed contribute here; detailed * accounts go through `buildDetailedLines`. THROWS a typed BalanceServiceError * on the first invalid value so no DB mutation happens on bad input (#176). * Exported for unit tests. */ export function buildSimpleLines( values: Record, detailedAccountIds: ReadonlySet ): SnapshotLineInput[] { return Object.entries(values) .filter( ([accountIdStr, v]) => !detailedAccountIds.has(Number(accountIdStr)) && v !== undefined && String(v).trim().length > 0 ) .map(([accountIdStr, raw]) => { const accountId = Number(accountIdStr); const num = parseDecimal(raw); if (num === null) { throw new BalanceServiceError( "snapshot_value_invalid", `Invalid value for account ${accountId}: "${raw}"` ); } return { account_id: accountId, value: num, account_kind: "simple" as const, }; }); } /** * Build the detailed-account `SnapshotLineInput[]` (one per account, each * carrying its `holdings` array) from the editor's `holdings` map. The presence * of the `holdings` field — even an empty array — marks the line detailed for * the service. Empty / blank holding rows (no symbol AND no qty AND no price) * are dropped before save so a half-typed row doesn't fail validation. THROWS a * typed error on a partially-filled row. The aggregated `value` is the SUM of * the rounded-cent holding values; the service re-rounds and re-validates it. * Exported for unit tests. */ export function buildDetailedLines( holdings: Record, detailedAccountIds: ReadonlySet ): SnapshotLineInput[] { const lines: SnapshotLineInput[] = []; for (const accountId of detailedAccountIds) { const drafts = holdings[accountId] ?? []; const built: SnapshotHoldingInput[] = []; for (const d of drafts) { const symbol = d.symbol.trim(); const qtyRaw = String(d.quantity ?? "").trim(); const priceRaw = String(d.unit_price ?? "").trim(); const isBlank = symbol.length === 0 && qtyRaw.length === 0 && priceRaw.length === 0; if (isBlank) continue; // skip an untouched / freshly-added empty row if (symbol.length === 0) { throw new BalanceServiceError( "snapshot_holding_invalid", `A holding for account ${accountId} is missing its symbol` ); } const qty = parseDecimal(d.quantity); const price = parseDecimal(d.unit_price); if (qty === null) { throw new BalanceServiceError( "snapshot_priced_quantity_required", `Invalid quantity for ${symbol} (account ${accountId}): "${d.quantity}"` ); } if (price === null) { throw new BalanceServiceError( "snapshot_priced_unit_price_required", `Invalid unit price for ${symbol} (account ${accountId}): "${d.unit_price}"` ); } const bookCost = parseDecimal(d.book_cost); const value = Math.round(qty * price * 100) / 100; built.push({ symbol, asset_type: d.asset_type, currency: d.currency || "CAD", security_name: d.security_name.trim() || null, quantity: qty, unit_price: price, value, book_cost: bookCost, price_source: d.price_source, price_fetched_at: d.price_fetched_at, }); } // Aggregated value = rounded-cent SUM of the holdings' rounded-cent values. const total = Math.round(built.reduce((s, h) => s + h.value, 0) * 100) / 100; lines.push({ account_id: accountId, value: total, // `holdings` present (even empty) ⇒ detailed save path; qty/price omitted. holdings: built, }); } return lines; } interface Options { /** ISO date from the route query string. `undefined` means 'new' mode. */ dateParam?: string | null; } export function useSnapshotEditor(options: Options = {}) { const { dateParam } = options; const [state, dispatch] = useReducer( reducer, undefined, () => initialState(dateParam ?? todayISO()) ); const fetchIdRef = useRef(0); /** * Load the editor state from the database. In 'new' mode we still load * accounts + categories + the previous snapshot (so the prefill button * can be enabled); we do NOT pre-create a snapshot row — that happens at * save time so the user can abandon the form without leaving an empty * snapshot behind. */ const loadForDate = useCallback(async (date: string | null | undefined) => { const fetchId = ++fetchIdRef.current; dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_ERROR", payload: { message: null, code: null } }); const targetDate = date && date.length > 0 ? date : todayISO(); try { const [accounts, categories] = await Promise.all([ listBalanceAccounts(), listBalanceCategories(), ]); const values: Record = {}; const holdings: Record = {}; let previousLines: BalanceSnapshotLine[] | null = null; // Index each account's OWN kind (simple|detailed) — this, not the // category kind, decides which input map a line belongs to (#213). const accountById = new Map(); for (const acc of accounts) accountById.set(acc.id, acc); const existing = await getSnapshotByDate(targetDate); const isEdit = !!existing; if (existing) { const lines = await listLinesBySnapshot(existing.id); for (const line of lines) { const acc = accountById.get(line.account_id); if (acc?.kind === "detailed") { // Hydrate the basket from this line's persisted holdings. const rows = await listHoldingsBySnapshotLine(line.id); holdings[line.account_id] = holdingsFromServiceHoldings(rows, { keepPrice: true, }); } else { values[line.account_id] = String(line.value); } } // Detailed accounts with NO line yet at this snapshot still get an // (empty) basket so the editor renders the detailed variant for them. for (const acc of accounts) { if (acc.kind === "detailed" && holdings[acc.id] === undefined) { holdings[acc.id] = []; } } } else { // 'new' mode: prefill detailed baskets from each account's latest // snapshot holdings (qty-0 excluded server-side), price dropped. for (const acc of accounts) { if (acc.kind === "detailed") { const rows = await getHoldingsForLatestSnapshot(acc.id); holdings[acc.id] = holdingsFromServiceHoldings(rows, { keepPrice: false, }); } } } const previous = await getPreviousSnapshot(targetDate); if (previous) { previousLines = await listLinesBySnapshot(previous.id); } if (fetchId !== fetchIdRef.current) return; dispatch({ type: "LOADED", payload: { mode: isEdit ? "edit" : "new", snapshotDate: targetDate, snapshot: existing, accounts, categories, values, holdings, previousSnapshot: previous, previousLines, }, }); } catch (e) { if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_ERROR", payload: describeError(e) }); } }, []); // Load on mount + whenever the route's `?date=` changes. useEffect(() => { loadForDate(dateParam); }, [dateParam, loadForDate]); const setDate = useCallback((next: string) => { dispatch({ type: "SET_DATE", payload: next }); }, []); const setLineValue = useCallback((accountId: number, value: string) => { dispatch({ type: "SET_VALUE", payload: { accountId, value }, }); }, []); const addHolding = useCallback( (accountId: number, assetType: BalanceAssetType = "stock") => { dispatch({ type: "ADD_HOLDING", payload: { accountId, holding: makeEmptyHolding(assetType) }, }); }, [] ); const removeHolding = useCallback((accountId: number, rowId: string) => { dispatch({ type: "REMOVE_HOLDING", payload: { accountId, rowId } }); }, []); const setHoldingField = useCallback( ( accountId: number, rowId: string, field: keyof Omit, value: string ) => { dispatch({ type: "SET_HOLDING_FIELD", payload: { accountId, rowId, field, value }, }); }, [] ); const reset = useCallback(() => { dispatch({ type: "RESET" }); }, []); /** * Build the prefill map from the previous snapshot (simple accounts only — * detailed accounts are prefilled from their latest holdings at LOAD time in * 'new' mode, which is more accurate than copying the previous *line*). Per * spec-decisions row "Bouton Pré-remplir": simple → copy value. */ const prefillFromPrevious = useCallback(() => { const lines = state.previousLines; if (!lines || lines.length === 0) return; const accountById = new Map(); for (const acc of state.accounts) accountById.set(acc.id, acc); const nextSimple: Record = {}; for (const line of lines) { const acc = accountById.get(line.account_id); if (!acc) continue; // archived account — skip if (acc.kind === "simple") { nextSimple[line.account_id] = String(line.value); } // Detailed accounts: intentionally NOT prefilled from the previous line // here — their basket was already hydrated from the latest holdings. } dispatch({ type: "PREFILL", payload: { values: nextSimple, holdings: {} }, }); }, [state.previousLines, state.accounts]); /** * Persist the editor state to the database (#176 — atomic; #213 — detailed). * * Order of operations: * 1. Build & validate `simpleLines` (scalar) and `detailedLines` (holdings) * from editor state. Any input parsing error throws BEFORE any DB * mutation, so an invalid form never produces an orphan snapshot row. * 2. Call `saveSnapshotAtomic` which wraps the snapshot INSERT (new mode), * the line rewrite AND the holdings rewrite in a single BEGIN/COMMIT/ * ROLLBACK transaction. * * Modes: * - 'new' mode: atomic helper inserts the snapshot row + its lines/holdings. * - 'edit' mode: only the lines/holdings get rewritten on the existing row. */ const save = useCallback(async (): Promise<{ snapshotId: number }> => { dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_ERROR", payload: { message: null, code: null } }); try { // Set of detailed account ids — dispatched on each account's OWN kind. const detailedAccountIds = new Set(); for (const acc of state.accounts) { if (acc.kind === "detailed") detailedAccountIds.add(acc.id); } // Step 1 — build & validate every line in memory. THROW HERE means no DB // mutation has happened yet, so no orphan snapshot can be left behind by // a validation failure (#176). const simpleLines = buildSimpleLines(state.values, detailedAccountIds); const detailedLines = buildDetailedLines( state.holdings, detailedAccountIds ); // Step 2 — atomic write. BEGIN / INSERT snapshot (if 'new') / // INSERT lines + holdings / COMMIT, with ROLLBACK on any failure. const existingSnapshotId = state.mode === "edit" && state.snapshot ? state.snapshot.id : null; // Edit-mode date move (#200): when the user changed the date of an // existing snapshot, forward the new date so the atomic save moves the // row (preserving its lines) in the same transaction. A collision // surfaces as `snapshot_date_exists` and rolls back. const moveToDate = state.mode === "edit" && state.snapshot && state.snapshotDate !== state.snapshot.snapshot_date ? state.snapshotDate : null; const { snapshotId } = await saveSnapshotAtomic({ existingSnapshotId, snapshot_date: state.snapshotDate, lines: [...simpleLines, ...detailedLines], moveToDate, }); dispatch({ type: "CLEAR_DIRTY" }); // Reload so 'new' mode flips to 'edit' and the snapshot row is in state. await loadForDate(state.snapshotDate); return { snapshotId }; } catch (e) { dispatch({ type: "SET_ERROR", payload: describeError(e) }); throw e; } finally { dispatch({ type: "SET_SAVING", payload: false }); } }, [ state.mode, state.snapshot, state.snapshotDate, state.values, state.holdings, state.accounts, loadForDate, ]); const remove = useCallback(async () => { if (!state.snapshot) return; dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_ERROR", payload: { message: null, code: null } }); try { await deleteSnapshot(state.snapshot.id); } catch (e) { dispatch({ type: "SET_ERROR", payload: describeError(e) }); throw e; } finally { dispatch({ type: "SET_SAVING", payload: false }); } }, [state.snapshot]); return { state, setDate, setLineValue, addHolding, removeHolding, setHoldingField, reset, prefillFromPrevious, save, remove, /** Manual reload (e.g. after navigation between dates). */ reload: () => loadForDate(state.snapshotDate), }; }