// BalanceAccountsTable — one-row-per-active-account table on /balance. // // Issue #141 (Bilan #3) introduced the table with name/category/latest-value/Δ% // + actions menu. Issue #142 (Bilan #4) adds 4 return columns, computed via // the Modified Dietz `compute_account_return` Tauri command: // // - 3M (last 90 days) // - 1A (last 365 days) // - Depuis création (from earliest snapshot date to today) // - Non-ajusté (simple `(V_end - V_start) / V_start`, no contribution // weighting — shown side-by-side as a sanity check / explanation) // // Returns load lazily on mount via `Promise.all` over (account × horizon), // keyed by `account_id`. Each cell renders "—" while loading and shows the // `is_partial` / `has_no_transfers_warning` badges via tooltip when set. // // Issue #142 also adds a "Lier transferts" item in the per-row actions menu // that opens `LinkTransfersModal` (the modal handles its own state; this // component just bubbles up the request via `onLinkTransfers`). import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Archive, MoreVertical, Link as LinkIcon, AlertTriangle, ChevronDown, ChevronRight, } from "lucide-react"; import type { AccountLatestSnapshot, AccountPeriodAnchor, } from "../../services/balance.service"; import { computeAccountReturn } from "../../services/balance.service"; import { getPreference, setPreference, } from "../../services/userPreferenceService"; import type { AccountReturn } from "../../shared/types"; import { renderCategoryLabelFromAccount } from "../../utils/renderCategoryLabel"; /** * Preference key persisting whether the 4 Modified-Dietz return columns are * expanded. Absent/anything-but-"1" → collapsed (the spec default). Stored via * `userPreferenceService` so the choice survives across sessions (Issue #204). */ const SHOW_RETURNS_PREF_KEY = "balance_show_returns"; const cadFormatter = (locale: string) => new Intl.NumberFormat(locale, { style: "currency", currency: "CAD", maximumFractionDigits: 2, }); /** Horizon definition: how many days back from today to start the period. */ type HorizonKey = "3M" | "1A" | "since"; interface HorizonRange { key: HorizonKey; /** ISO date for `period_start`. */ from: string; /** ISO date for `period_end` (always today, computed in the local civil day). */ to: string; } function localISO(d: Date): string { const yy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); return `${yy}-${mm}-${dd}`; } function isoDaysAgo(days: number, today: Date): string { const d = new Date(today); d.setDate(d.getDate() - days); return localISO(d); } interface BalanceAccountsTableProps { accounts: AccountLatestSnapshot[]; periodAnchor: AccountPeriodAnchor[]; onArchiveAccount?: (account: AccountLatestSnapshot) => void; onLinkTransfers?: (account: AccountLatestSnapshot) => void; /** * Earliest snapshot date across the whole profile, used to anchor the * "depuis création" horizon. Falls back to "1A" range if not provided * (avoids triggering computation against the unix epoch). */ sinceCreationDate?: string | null; } /** * Per-account, per-horizon return — shape used by the local cache state. * Indexed `[accountId][horizonKey]`. */ type ReturnsByAccount = Record>>; export default function BalanceAccountsTable({ accounts, periodAnchor, onArchiveAccount, onLinkTransfers, sinceCreationDate, }: BalanceAccountsTableProps) { const { t, i18n } = useTranslation(); const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA"); /** account_id → period anchor (start-of-period value). */ const anchorMap = useMemo(() => { const m = new Map(); for (const a of periodAnchor) m.set(a.account_id, a); return m; }, [periodAnchor]); const [openMenuFor, setOpenMenuFor] = useState(null); // Progressive disclosure of the 4 return columns (Issue #204). Collapsed by // default; the persisted choice is read once on mount. We start `false` so // the columns never flash open before the preference resolves. const [showReturns, setShowReturns] = useState(false); useEffect(() => { let cancelled = false; void (async () => { try { const stored = await getPreference(SHOW_RETURNS_PREF_KEY); if (!cancelled && stored === "1") setShowReturns(true); } catch { // Pref read failure: keep the collapsed default. } })(); return () => { cancelled = true; }; }, []); const toggleReturns = () => { setShowReturns((prev) => { const next = !prev; // Best-effort persist; a write failure just means the next session // falls back to the collapsed default. void setPreference(SHOW_RETURNS_PREF_KEY, next ? "1" : "0").catch( () => {} ); return next; }); }; // Returns cache. Cleared whenever the account list changes (new accounts, // archive, etc.). Loaded lazily after mount — only while the columns are // shown, so a collapsed table never runs the Modified-Dietz computation. const [returns, setReturns] = useState({}); const [returnsLoading, setReturnsLoading] = useState(false); // Horizon definitions — recomputed once per mount via today's local civil // day. We don't memoize against `accounts` because the dates don't depend // on the row list. const horizons = useMemo(() => { const today = new Date(); const todayISO = localISO(today); const sinceFrom = sinceCreationDate ?? isoDaysAgo(365, today); return [ { key: "3M", from: isoDaysAgo(90, today), to: todayISO }, { key: "1A", from: isoDaysAgo(365, today), to: todayISO }, { key: "since", from: sinceFrom, to: todayISO }, ]; }, [sinceCreationDate]); useEffect(() => { let cancelled = false; async function loadReturns() { if (!showReturns || accounts.length === 0) { setReturns({}); return; } setReturnsLoading(true); const next: ReturnsByAccount = {}; // Run sequentially per account to avoid SQLite contention; per-horizon // we can parallelize because they hit the same table set. await Promise.all( accounts.map(async (acc) => { next[acc.account_id] = {}; const tasks = horizons.map(async (h) => { try { const r = await computeAccountReturn( acc.account_id, h.from, h.to ); next[acc.account_id]![h.key] = r; } catch { // Per-cell failure: leave the slot undefined → renders "—". } }); await Promise.all(tasks); }) ); if (!cancelled) { setReturns(next); setReturnsLoading(false); } } void loadReturns(); return () => { cancelled = true; }; }, [accounts, horizons, showReturns]); if (accounts.length === 0) { return (
{t("balance.overview.noAccounts")}
); } /** Format a return percentage with sign + colour-aware classname. */ function renderReturnCell(r: AccountReturn | undefined) { if (!r) { return ; } if (r.return_pct === null) { return ( ); } const pct = r.return_pct * 100; return ( = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" } > {pct >= 0 ? "+" : ""} {pct.toFixed(2)}% {r.has_no_transfers_warning && ( )} ); } /** * Unadjusted (simple) return = `(value_end - value_start) / value_start` * — same numbers Modified Dietz already returns when no flows exist, but * this column shows the simple version for ALL accounts as a side-by-side * sanity check. Computed from the same `AccountReturn` payload (uses the * `value_start` / `value_end` fields filled by the Rust side). */ function renderUnadjustedCell(r: AccountReturn | undefined) { if (!r || r.value_start === null || r.value_end === null) { return ; } if (r.value_start === 0) { return ; } const simple = ((r.value_end - r.value_start) / r.value_start) * 100; return ( = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" } > {simple >= 0 ? "+" : ""} {simple.toFixed(2)}% ); } return (
{showReturns && ( <> )} {accounts.map((acc) => { const anchor = anchorMap.get(acc.account_id); const deltaPct = acc.latest_value !== null && anchor && anchor.anchor_value !== 0 ? ((acc.latest_value - anchor.anchor_value) / Math.abs(anchor.anchor_value)) * 100 : null; const accReturns = returns[acc.account_id] ?? {}; return ( {showReturns && ( <> )} ); })}
{t("balance.account.fields.name")} {t("balance.account.fields.category")} {t("balance.overview.latestValue")} {t("balance.overview.periodDelta")} {t("balance.accountsTable.return3m")} {t("balance.accountsTable.return1y")} {t("balance.accountsTable.sinceCreation")} {t("balance.accountsTable.unadjusted")} {t("balance.account.fields.actions")}
{acc.account_name} {acc.symbol ? ( ({acc.symbol}) ) : null} {renderCategoryLabelFromAccount(acc, t)} {acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"} {deltaPct !== null ? ( = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" } > {deltaPct >= 0 ? "+" : ""} {deltaPct.toFixed(2)}% ) : ( "—" )} {returnsLoading && !accReturns["3M"] ? "…" : renderReturnCell(accReturns["3M"])} {returnsLoading && !accReturns["1A"] ? "…" : renderReturnCell(accReturns["1A"])} {returnsLoading && !accReturns["since"] ? "…" : renderReturnCell(accReturns["since"])} {returnsLoading && !accReturns["1A"] ? "…" : renderUnadjustedCell(accReturns["1A"])} {openMenuFor === acc.account_id && (
{onLinkTransfers && ( )}
)}
); }