Surface unrealized (latent) gain on the existing balance surfaces, no new visualization (decision 2026-06-04). Service: - AccountLatestSnapshot gains account `kind` + `latest_snapshot_line_id`, so the table knows which rows are detailed and where their holdings live. - getAccountLatentGainByLine(lineId) folds a line's holdings through the existing computeUnrealizedGain (shares the book_cost=0 / NULL -> N/A guard). - rollupLatentGain(accounts): pure aggregation by asset class, by envelope (vehicle_type, 'none' bucket), and grand total; per-bucket % denominator is the known book_cost only (null when none), mirroring the aggregate guard. Hook (useBalanceOverview): - Prefetches per-detailed-account latent gain in parallel (failures isolated), exposes latentGainByAccount + latentGainRollup. Simple accounts skipped. UI: - BalanceAccountsTable: expandable detailed rows -> per-security value + latent gain %; a latent-gain column (shown only when a detailed account has one); a summary block aggregating latent gain by asset class / envelope. - BalanceOverviewCard: total latent gain line (hidden without detailed accounts). - N/A rendered (never divide-by-zero) when book_cost is NULL or 0; partial-% flag when some positions lack a cost basis. Native non-CAD currency display de-scoped this round (untestable while all securities are CAD). Modified Dietz return columns (#204) unchanged. i18n: balance.latentGain.* in FR + EN. Focused unit tests for getAccountLatentGainByLine and rollupLatentGain (grouping, 'none' envelope, null-% / unknown book_cost, empty). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
259 lines
9 KiB
TypeScript
259 lines
9 KiB
TypeScript
// useBalanceOverview — scoped useReducer hook backing BalancePage.
|
|
//
|
|
// Domain coverage (per spec-plan-bilan.md v2 / Issue #141, extended in #204):
|
|
// - Time-series for the evolution chart (totals + per-category + per-vehicle)
|
|
// - Per-account latest snapshot value + period-anchor value (for Δ%)
|
|
// - Period selector (3M / 6M / 1A / 3A / Tout)
|
|
// - Chart mode toggle (line / stacked-area)
|
|
// - Group-axis toggle for the stacked mode (asset class / fiscal envelope)
|
|
//
|
|
// `chartMode` (line/stacked) and `groupAxis` (class/vehicle) are two ORTHOGONAL
|
|
// dimensions: `groupAxis` only changes which breakdown feeds the stacked chart;
|
|
// it has no effect in line mode. They are kept as independent state to avoid
|
|
// conflating "how to draw" with "what to group by" (Issue #204).
|
|
//
|
|
// Returns (Modified Dietz) are loaded by the accounts table itself, not here.
|
|
|
|
import { useReducer, useEffect, useCallback } from "react";
|
|
import {
|
|
getSnapshotTotalsByDate,
|
|
getSnapshotTotalsByCategoryAndDate,
|
|
getSnapshotTotalsByVehicleAndDate,
|
|
getAccountsLatestSnapshot,
|
|
getAccountsPeriodAnchor,
|
|
getAccountLatentGainByLine,
|
|
rollupLatentGain,
|
|
type SnapshotTotalPoint,
|
|
type SnapshotCategoryBreakdownPoint,
|
|
type SnapshotVehicleBreakdownPoint,
|
|
type AccountLatestSnapshot,
|
|
type AccountPeriodAnchor,
|
|
type AccountUnrealizedGain,
|
|
type LatentGainRollup,
|
|
type SnapshotDateRange,
|
|
} from "../services/balance.service";
|
|
|
|
export type BalancePeriod = "3M" | "6M" | "1A" | "3A" | "all";
|
|
export type BalanceChartMode = "line" | "stacked";
|
|
/** Stacked-chart grouping axis: by asset class (category) or fiscal envelope. */
|
|
export type BalanceGroupAxis = "class" | "vehicle";
|
|
|
|
interface State {
|
|
period: BalancePeriod;
|
|
chartMode: BalanceChartMode;
|
|
/** Orthogonal to `chartMode`; only meaningful in stacked mode. Default 'class'. */
|
|
groupAxis: BalanceGroupAxis;
|
|
evolutionTotals: SnapshotTotalPoint[];
|
|
evolutionByCategory: SnapshotCategoryBreakdownPoint[];
|
|
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
|
|
accountsLatest: AccountLatestSnapshot[];
|
|
accountsPeriodAnchor: AccountPeriodAnchor[];
|
|
/**
|
|
* Per-account unrealized (latent) gain keyed by `account_id`, computed from
|
|
* each detailed account's LATEST snapshot-line holdings (Issue #216). Only
|
|
* detailed accounts with holdings appear; simple accounts are absent.
|
|
*/
|
|
latentGainByAccount: Record<number, AccountUnrealizedGain>;
|
|
/** Latent gain rolled up by asset class, by envelope, and grand total (#216). */
|
|
latentGainRollup: LatentGainRollup;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
/** Empty rollup — initial state and the no-detailed-account case. */
|
|
const EMPTY_ROLLUP: LatentGainRollup = {
|
|
byClass: [],
|
|
byVehicle: [],
|
|
grandTotal: {
|
|
total_value: 0,
|
|
total_book_cost: 0,
|
|
total_gain: 0,
|
|
total_gain_pct: null,
|
|
has_unknown_book_cost: false,
|
|
},
|
|
};
|
|
|
|
type Action =
|
|
| { type: "SET_PERIOD"; payload: BalancePeriod }
|
|
| { type: "SET_CHART_MODE"; payload: BalanceChartMode }
|
|
| { type: "SET_GROUP_AXIS"; payload: BalanceGroupAxis }
|
|
| { type: "LOAD_START" }
|
|
| {
|
|
type: "LOAD_SUCCESS";
|
|
payload: {
|
|
evolutionTotals: SnapshotTotalPoint[];
|
|
evolutionByCategory: SnapshotCategoryBreakdownPoint[];
|
|
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
|
|
accountsLatest: AccountLatestSnapshot[];
|
|
accountsPeriodAnchor: AccountPeriodAnchor[];
|
|
latentGainByAccount: Record<number, AccountUnrealizedGain>;
|
|
latentGainRollup: LatentGainRollup;
|
|
};
|
|
}
|
|
| { type: "LOAD_ERROR"; payload: string };
|
|
|
|
function initialState(): State {
|
|
return {
|
|
period: "1A",
|
|
chartMode: "line",
|
|
groupAxis: "class",
|
|
evolutionTotals: [],
|
|
evolutionByCategory: [],
|
|
evolutionByVehicle: [],
|
|
accountsLatest: [],
|
|
accountsPeriodAnchor: [],
|
|
latentGainByAccount: {},
|
|
latentGainRollup: EMPTY_ROLLUP,
|
|
isLoading: false,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
function reducer(state: State, action: Action): State {
|
|
switch (action.type) {
|
|
case "SET_PERIOD":
|
|
return { ...state, period: action.payload };
|
|
case "SET_CHART_MODE":
|
|
return { ...state, chartMode: action.payload };
|
|
case "SET_GROUP_AXIS":
|
|
return { ...state, groupAxis: action.payload };
|
|
case "LOAD_START":
|
|
return { ...state, isLoading: true, error: null };
|
|
case "LOAD_SUCCESS":
|
|
return {
|
|
...state,
|
|
...action.payload,
|
|
isLoading: false,
|
|
error: null,
|
|
};
|
|
case "LOAD_ERROR":
|
|
return { ...state, isLoading: false, error: action.payload };
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pure helper: turn a `BalancePeriod` into a `SnapshotDateRange` anchored on
|
|
* the supplied `today` (defaults to now). Exported so the unit tests can
|
|
* exercise the date math without mocking time.
|
|
*
|
|
* Period anchor decision (decisions-log #141): we anchor on `today`, not on
|
|
* the latest snapshot. Aggregators read snapshot rows so the answer is
|
|
* identical either way, but anchoring on today keeps the chart's right edge
|
|
* stable as the user enters new snapshots — intuitive UX.
|
|
*/
|
|
export function computeBalanceDateRange(
|
|
period: BalancePeriod,
|
|
today: Date = new Date()
|
|
): SnapshotDateRange {
|
|
if (period === "all") return {};
|
|
const days =
|
|
period === "3M" ? 90 : period === "6M" ? 180 : period === "1A" ? 365 : 1095;
|
|
const from = new Date(today);
|
|
from.setDate(from.getDate() - days);
|
|
// Local-civil `YYYY-MM-DD` (matches normalizeSnapshotDate's expectations).
|
|
const yyyy = from.getFullYear();
|
|
const mm = String(from.getMonth() + 1).padStart(2, "0");
|
|
const dd = String(from.getDate()).padStart(2, "0");
|
|
return { from: `${yyyy}-${mm}-${dd}` };
|
|
}
|
|
|
|
export interface UseBalanceOverviewResult {
|
|
state: State;
|
|
setPeriod: (period: BalancePeriod) => void;
|
|
setChartMode: (mode: BalanceChartMode) => void;
|
|
setGroupAxis: (axis: BalanceGroupAxis) => void;
|
|
reload: () => Promise<void>;
|
|
}
|
|
|
|
export function useBalanceOverview(): UseBalanceOverviewResult {
|
|
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
|
|
|
const load = useCallback(async (period: BalancePeriod) => {
|
|
dispatch({ type: "LOAD_START" });
|
|
try {
|
|
const range = computeBalanceDateRange(period);
|
|
// Parallel fetches — no inter-dependency between the queries.
|
|
const [totals, byCategory, byVehicle, latest, anchors] =
|
|
await Promise.all([
|
|
getSnapshotTotalsByDate(range),
|
|
getSnapshotTotalsByCategoryAndDate(range),
|
|
getSnapshotTotalsByVehicleAndDate(range),
|
|
getAccountsLatestSnapshot(),
|
|
getAccountsPeriodAnchor(range),
|
|
]);
|
|
|
|
// Latent gain (Issue #216): only detailed accounts that actually carry a
|
|
// latest snapshot line can have holdings. Fetch each in parallel, fold the
|
|
// holdings through the shared unrealized-gain guard, then roll up by asset
|
|
// class / envelope. Per-account fetch failures are isolated (the account
|
|
// simply has no latent-gain figure). Simple accounts are skipped entirely.
|
|
const detailed = latest.filter(
|
|
(a) => a.kind === "detailed" && a.latest_snapshot_line_id != null
|
|
);
|
|
const latentEntries = await Promise.all(
|
|
detailed.map(async (a) => {
|
|
try {
|
|
const gain = await getAccountLatentGainByLine(
|
|
a.latest_snapshot_line_id as number
|
|
);
|
|
return { account: a, gain };
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
);
|
|
const latentGainByAccount: Record<number, AccountUnrealizedGain> = {};
|
|
for (const e of latentEntries) {
|
|
if (e) latentGainByAccount[e.account.account_id] = e.gain;
|
|
}
|
|
const latentGainRollup = rollupLatentGain(
|
|
latentEntries
|
|
.filter((e): e is NonNullable<typeof e> => e !== null)
|
|
.map((e) => ({
|
|
category_key: e.account.category_key,
|
|
vehicle_type: e.account.vehicle_type,
|
|
gain: e.gain,
|
|
}))
|
|
);
|
|
|
|
dispatch({
|
|
type: "LOAD_SUCCESS",
|
|
payload: {
|
|
evolutionTotals: totals,
|
|
evolutionByCategory: byCategory,
|
|
evolutionByVehicle: byVehicle,
|
|
accountsLatest: latest,
|
|
accountsPeriodAnchor: anchors,
|
|
latentGainByAccount,
|
|
latentGainRollup,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
dispatch({ type: "LOAD_ERROR", payload: message });
|
|
}
|
|
}, []);
|
|
|
|
// Reload whenever the period changes (and on mount).
|
|
useEffect(() => {
|
|
void load(state.period);
|
|
}, [state.period, load]);
|
|
|
|
const setPeriod = useCallback((period: BalancePeriod) => {
|
|
dispatch({ type: "SET_PERIOD", payload: period });
|
|
}, []);
|
|
|
|
const setChartMode = useCallback((mode: BalanceChartMode) => {
|
|
dispatch({ type: "SET_CHART_MODE", payload: mode });
|
|
}, []);
|
|
|
|
const setGroupAxis = useCallback((axis: BalanceGroupAxis) => {
|
|
dispatch({ type: "SET_GROUP_AXIS", payload: axis });
|
|
}, []);
|
|
|
|
const reload = useCallback(() => load(state.period), [load, state.period]);
|
|
|
|
return { state, setPeriod, setChartMode, setGroupAxis, reload };
|
|
}
|