Compare commits
2 commits
c4edfb0a35
...
1a4cab2e9b
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a4cab2e9b | |||
|
|
76ddad66c9 |
8 changed files with 729 additions and 7 deletions
|
|
@ -18,7 +18,8 @@
|
|||
// 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 { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Archive,
|
||||
|
|
@ -31,6 +32,8 @@ import {
|
|||
import type {
|
||||
AccountLatestSnapshot,
|
||||
AccountPeriodAnchor,
|
||||
AccountUnrealizedGain,
|
||||
LatentGainRollup,
|
||||
} from "../../services/balance.service";
|
||||
import { computeAccountReturn } from "../../services/balance.service";
|
||||
import {
|
||||
|
|
@ -89,6 +92,17 @@ interface BalanceAccountsTableProps {
|
|||
* (avoids triggering computation against the unix epoch).
|
||||
*/
|
||||
sinceCreationDate?: string | null;
|
||||
/**
|
||||
* Per-account unrealized (latent) gain keyed by `account_id`, prefetched by
|
||||
* `useBalanceOverview` from each detailed account's latest holdings (Issue
|
||||
* #216). Drives the latent-gain column + the per-security drill-down. Only
|
||||
* detailed accounts with holdings appear; absent ⇒ no figure / no drill-down.
|
||||
*/
|
||||
latentGainByAccount?: Record<number, AccountUnrealizedGain>;
|
||||
/** Latent gain rolled up by asset class / envelope, for the summary block (#216). */
|
||||
latentGainRollup?: LatentGainRollup;
|
||||
/** vehicle_type code (incl. 'none') → translated label, for the envelope rollup (#216). */
|
||||
vehicleLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -103,6 +117,9 @@ export default function BalanceAccountsTable({
|
|||
onArchiveAccount,
|
||||
onLinkTransfers,
|
||||
sinceCreationDate,
|
||||
latentGainByAccount = {},
|
||||
latentGainRollup,
|
||||
vehicleLabels = {},
|
||||
}: BalanceAccountsTableProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
|
||||
|
|
@ -116,6 +133,20 @@ export default function BalanceAccountsTable({
|
|||
|
||||
const [openMenuFor, setOpenMenuFor] = useState<number | null>(null);
|
||||
|
||||
// Per-security drill-down (Issue #216): which detailed account rows are
|
||||
// expanded. A Set keyed by account_id; toggled by the row's chevron. The
|
||||
// holdings themselves are already prefetched in `latentGainByAccount`, so
|
||||
// expanding is a pure render — no extra DB round-trip.
|
||||
const [expandedFor, setExpandedFor] = useState<Set<number>>(new Set());
|
||||
const toggleExpanded = (accountId: number) => {
|
||||
setExpandedFor((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(accountId)) next.delete(accountId);
|
||||
else next.add(accountId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 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.
|
||||
|
|
@ -280,6 +311,58 @@ export default function BalanceAccountsTable({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Latent (unrealized) gain cell — value + % (Issue #216). `gain`/`gainPct`
|
||||
* null ⇒ the shared service guard already decided "N/A" (book_cost NULL or 0);
|
||||
* render the i18n N/A string, never a divide-by-zero. `partial` flags an
|
||||
* aggregate where some holdings had an unknown book_cost (% understated).
|
||||
*/
|
||||
function renderLatentGainCell(
|
||||
gain: number | null,
|
||||
gainPct: number | null,
|
||||
partial = false
|
||||
) {
|
||||
if (gain === null) {
|
||||
return (
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{t("balance.latentGain.na")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const positive = gain >= 0;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-end gap-1 ${
|
||||
positive ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{positive ? "+" : ""}
|
||||
{fmt.format(gain)}
|
||||
</span>
|
||||
{gainPct !== null && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
({positive ? "+" : ""}
|
||||
{(gainPct * 100).toFixed(2)}%)
|
||||
</span>
|
||||
)}
|
||||
{partial && (
|
||||
<AlertTriangle
|
||||
size={12}
|
||||
className="text-amber-500"
|
||||
aria-label={t("balance.latentGain.partial")}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// The latent-gain column only earns its place when at least one detailed
|
||||
// account has a computed gain — otherwise we keep the table narrow.
|
||||
const hasLatentGain = accounts.some(
|
||||
(a) => latentGainByAccount[a.account_id] !== undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-end">
|
||||
|
|
@ -317,6 +400,14 @@ export default function BalanceAccountsTable({
|
|||
<th className="text-right px-4 py-3 font-medium">
|
||||
{t("balance.overview.periodDelta")}
|
||||
</th>
|
||||
{hasLatentGain && (
|
||||
<th
|
||||
className="text-right px-4 py-3 font-medium"
|
||||
title={t("balance.latentGain.tooltip")}
|
||||
>
|
||||
{t("balance.latentGain.column")}
|
||||
</th>
|
||||
)}
|
||||
{showReturns && (
|
||||
<>
|
||||
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
|
||||
|
|
@ -348,13 +439,44 @@ export default function BalanceAccountsTable({
|
|||
100
|
||||
: null;
|
||||
const accReturns = returns[acc.account_id] ?? {};
|
||||
const lg = latentGainByAccount[acc.account_id];
|
||||
// A detailed account is drillable once its latest holdings are
|
||||
// loaded. Simple accounts (no holdings) never expand.
|
||||
const drillable = !!lg && lg.holdings.length > 0;
|
||||
const isExpanded = drillable && expandedFor.has(acc.account_id);
|
||||
// Number of columns a drill-down sub-row must span.
|
||||
const colSpan =
|
||||
5 + (hasLatentGain ? 1 : 0) + (showReturns ? 4 : 0);
|
||||
return (
|
||||
<Fragment key={acc.account_id}>
|
||||
<tr
|
||||
key={acc.account_id}
|
||||
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{drillable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(acc.account_id)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={t(
|
||||
isExpanded
|
||||
? "balance.latentGain.drilldown.collapse"
|
||||
: "balance.latentGain.drilldown.expand"
|
||||
)}
|
||||
className="p-0.5 -ml-1 rounded hover:bg-[var(--muted)]/40 text-[var(--muted-foreground)]"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-block w-[14px]" aria-hidden />
|
||||
)}
|
||||
{acc.account_name}
|
||||
</span>
|
||||
{acc.symbol ? (
|
||||
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
|
||||
({acc.symbol})
|
||||
|
|
@ -383,6 +505,17 @@ export default function BalanceAccountsTable({
|
|||
"—"
|
||||
)}
|
||||
</td>
|
||||
{hasLatentGain && (
|
||||
<td className="px-4 py-3 text-right tabular-nums">
|
||||
{lg
|
||||
? renderLatentGainCell(
|
||||
lg.total_gain,
|
||||
lg.total_gain_pct,
|
||||
lg.has_unknown_book_cost
|
||||
)
|
||||
: "—"}
|
||||
</td>
|
||||
)}
|
||||
{showReturns && (
|
||||
<>
|
||||
<td className="px-4 py-3 text-right tabular-nums">
|
||||
|
|
@ -458,11 +591,147 @@ export default function BalanceAccountsTable({
|
|||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className="bg-[var(--muted)]/5">
|
||||
<td colSpan={colSpan} className="px-0 py-0">
|
||||
<div className="px-4 py-2">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-[var(--muted-foreground)]">
|
||||
<th className="text-left font-medium py-1 pl-6">
|
||||
{t("balance.latentGain.drilldown.security")}
|
||||
</th>
|
||||
<th className="text-right font-medium py-1">
|
||||
{t("balance.latentGain.drilldown.value")}
|
||||
</th>
|
||||
<th className="text-right font-medium py-1 pr-2">
|
||||
{t("balance.latentGain.drilldown.gain")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lg!.holdings.map((h) => (
|
||||
<tr key={h.security_id}>
|
||||
<td className="text-left py-1 pl-6 font-medium">
|
||||
{h.symbol}
|
||||
</td>
|
||||
<td className="text-right py-1 tabular-nums">
|
||||
{fmt.format(h.value)}
|
||||
</td>
|
||||
<td className="text-right py-1 pr-2 tabular-nums">
|
||||
{renderLatentGainCell(h.gain, h.gain_pct)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{latentGainRollup &&
|
||||
(latentGainRollup.byClass.length > 0 ||
|
||||
latentGainRollup.byVehicle.length > 0) && (
|
||||
<LatentGainSummary
|
||||
rollup={latentGainRollup}
|
||||
accounts={accounts}
|
||||
vehicleLabels={vehicleLabels}
|
||||
fmt={fmt}
|
||||
renderGain={renderLatentGainCell}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// LatentGainSummary — aggregated latent gain by asset class / envelope (#216).
|
||||
// Reuses the accounts surface (no new chart): a compact two-column block of
|
||||
// rollup figures. Asset-class labels resolve from the accounts payload (same
|
||||
// renderCategoryLabel path the table uses); envelope labels come from the
|
||||
// shared `vehicleLabels` map BalancePage already builds.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface LatentGainSummaryProps {
|
||||
rollup: LatentGainRollup;
|
||||
accounts: AccountLatestSnapshot[];
|
||||
vehicleLabels: Record<string, string>;
|
||||
fmt: Intl.NumberFormat;
|
||||
renderGain: (
|
||||
gain: number | null,
|
||||
gainPct: number | null,
|
||||
partial?: boolean
|
||||
) => ReactNode;
|
||||
}
|
||||
|
||||
function LatentGainSummary({
|
||||
rollup,
|
||||
accounts,
|
||||
vehicleLabels,
|
||||
renderGain,
|
||||
}: LatentGainSummaryProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// category_key → translated asset-class label, from the accounts payload.
|
||||
const classLabels = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const a of accounts) {
|
||||
if (!m[a.category_key]) {
|
||||
m[a.category_key] = renderCategoryLabelFromAccount(a, t);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [accounts, t]);
|
||||
|
||||
const section = (
|
||||
title: string,
|
||||
buckets: LatentGainRollup["byClass"],
|
||||
labelFor: (key: string) => string
|
||||
) => (
|
||||
<div className="flex-1 min-w-[220px]">
|
||||
<p className="text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wide mb-1">
|
||||
{title}
|
||||
</p>
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{buckets.map((b) => (
|
||||
<tr key={b.group_key} className="border-t border-[var(--border)]/60">
|
||||
<td className="py-1.5 text-[var(--muted-foreground)]">
|
||||
{labelFor(b.group_key)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{renderGain(b.total_gain, b.total_gain_pct, b.has_unknown_book_cost)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-3 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">
|
||||
{t("balance.latentGain.summary.title")}
|
||||
</h3>
|
||||
<div className="flex flex-col sm:flex-row gap-6">
|
||||
{section(
|
||||
t("balance.latentGain.summary.byClass"),
|
||||
rollup.byClass,
|
||||
(key) => classLabels[key] ?? key
|
||||
)}
|
||||
{section(
|
||||
t("balance.latentGain.summary.byVehicle"),
|
||||
rollup.byVehicle,
|
||||
(key) => vehicleLabels[key] ?? key
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import { useMemo } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { SnapshotTotalPoint } from "../../services/balance.service";
|
||||
import type {
|
||||
LatentGainRollup,
|
||||
SnapshotTotalPoint,
|
||||
} from "../../services/balance.service";
|
||||
|
||||
const STALENESS_DAYS = 60;
|
||||
const cadFormatter = (value: number) =>
|
||||
|
|
@ -25,9 +28,18 @@ const cadFormatter = (value: number) =>
|
|||
interface BalanceOverviewCardProps {
|
||||
/** The full evolution series for the active period (latest at the end). */
|
||||
totals: SnapshotTotalPoint[];
|
||||
/**
|
||||
* Aggregated latent gain across all detailed accounts (Issue #216). Shown as
|
||||
* a total figure alongside the net worth. Absent / no detailed account ⇒ the
|
||||
* latent-gain line is hidden (no zero-noise for users without securities).
|
||||
*/
|
||||
latentGainRollup?: LatentGainRollup;
|
||||
}
|
||||
|
||||
export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps) {
|
||||
export default function BalanceOverviewCard({
|
||||
totals,
|
||||
latentGainRollup,
|
||||
}: BalanceOverviewCardProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const summary = useMemo(() => {
|
||||
|
|
@ -58,6 +70,19 @@ export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps
|
|||
day: "numeric",
|
||||
});
|
||||
|
||||
// Total latent gain across detailed accounts. Only render when at least one
|
||||
// detailed account contributed a bucket — otherwise the line is hidden.
|
||||
const latent = useMemo(() => {
|
||||
if (
|
||||
!latentGainRollup ||
|
||||
(latentGainRollup.byClass.length === 0 &&
|
||||
latentGainRollup.byVehicle.length === 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return latentGainRollup.grandTotal;
|
||||
}, [latentGainRollup]);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
|
|
@ -75,6 +100,36 @@ export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps
|
|||
date: formatDate(summary.latest.snapshot_date),
|
||||
})}
|
||||
</p>
|
||||
{latent && (
|
||||
<p className="text-sm mt-2 inline-flex items-center gap-1.5">
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{t("balance.latentGain.totalLabel")}
|
||||
</span>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
latent.total_gain >= 0
|
||||
? "text-[var(--positive)]"
|
||||
: "text-[var(--negative)]"
|
||||
}`}
|
||||
>
|
||||
{latent.total_gain >= 0 ? "+" : ""}
|
||||
{cadFormatter(latent.total_gain)}
|
||||
{latent.total_gain_pct !== null && (
|
||||
<span className="text-[var(--muted-foreground)] font-normal text-xs ml-1">
|
||||
({latent.total_gain >= 0 ? "+" : ""}
|
||||
{(latent.total_gain_pct * 100).toFixed(2)}%)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{latent.has_unknown_book_cost && (
|
||||
<AlertTriangle
|
||||
size={12}
|
||||
className="text-amber-500"
|
||||
aria-label={t("balance.latentGain.partial")}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-2">
|
||||
|
|
|
|||
|
|
@ -21,11 +21,15 @@ import {
|
|||
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";
|
||||
|
||||
|
|
@ -44,10 +48,31 @@ interface State {
|
|||
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 }
|
||||
|
|
@ -61,6 +86,8 @@ type Action =
|
|||
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
|
||||
accountsLatest: AccountLatestSnapshot[];
|
||||
accountsPeriodAnchor: AccountPeriodAnchor[];
|
||||
latentGainByAccount: Record<number, AccountUnrealizedGain>;
|
||||
latentGainRollup: LatentGainRollup;
|
||||
};
|
||||
}
|
||||
| { type: "LOAD_ERROR"; payload: string };
|
||||
|
|
@ -75,6 +102,8 @@ function initialState(): State {
|
|||
evolutionByVehicle: [],
|
||||
accountsLatest: [],
|
||||
accountsPeriodAnchor: [],
|
||||
latentGainByAccount: {},
|
||||
latentGainRollup: EMPTY_ROLLUP,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
|
@ -154,6 +183,41 @@ export function useBalanceOverview(): UseBalanceOverviewResult {
|
|||
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: {
|
||||
|
|
@ -162,6 +226,8 @@ export function useBalanceOverview(): UseBalanceOverviewResult {
|
|||
evolutionByVehicle: byVehicle,
|
||||
accountsLatest: latest,
|
||||
accountsPeriodAnchor: anchors,
|
||||
latentGainByAccount,
|
||||
latentGainRollup,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -1839,6 +1839,25 @@
|
|||
"hide": "Hide returns"
|
||||
}
|
||||
},
|
||||
"latentGain": {
|
||||
"column": "Unrealized gain",
|
||||
"tooltip": "Latent gain on a detailed account: current value minus cost basis (book cost), in dollars and percent. Shown “—” when the cost basis is unknown.",
|
||||
"totalLabel": "Unrealized gain",
|
||||
"na": "—",
|
||||
"partial": "Partial: some positions have no recorded cost basis and are excluded from the percentage.",
|
||||
"drilldown": {
|
||||
"expand": "Show securities",
|
||||
"collapse": "Hide securities",
|
||||
"security": "Security",
|
||||
"value": "Value",
|
||||
"gain": "Unrealized gain"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Unrealized gain breakdown",
|
||||
"byClass": "By asset class",
|
||||
"byVehicle": "By envelope"
|
||||
}
|
||||
},
|
||||
"vehicle": {
|
||||
"none": "No envelope"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1839,6 +1839,25 @@
|
|||
"hide": "Masquer les rendements"
|
||||
}
|
||||
},
|
||||
"latentGain": {
|
||||
"column": "Gain latent",
|
||||
"tooltip": "Gain latent d'un compte détaillé : valeur actuelle moins le coût d'acquisition, en dollars et en pourcentage. Affiché « — » quand le coût d'acquisition est inconnu.",
|
||||
"totalLabel": "Gain latent",
|
||||
"na": "—",
|
||||
"partial": "Partiel : certaines positions n'ont pas de coût d'acquisition saisi et sont exclues du pourcentage.",
|
||||
"drilldown": {
|
||||
"expand": "Afficher les titres",
|
||||
"collapse": "Masquer les titres",
|
||||
"security": "Titre",
|
||||
"value": "Valeur",
|
||||
"gain": "Gain latent"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Répartition du gain latent",
|
||||
"byClass": "Par classe d'actif",
|
||||
"byVehicle": "Par enveloppe"
|
||||
}
|
||||
},
|
||||
"vehicle": {
|
||||
"none": "Sans enveloppe"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -215,7 +215,10 @@ export default function BalancePage() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BalanceOverviewCard totals={state.evolutionTotals} />
|
||||
<BalanceOverviewCard
|
||||
totals={state.evolutionTotals}
|
||||
latentGainRollup={state.latentGainRollup}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
{/* Period selector */}
|
||||
|
|
@ -316,6 +319,9 @@ export default function BalancePage() {
|
|||
accounts={state.accountsLatest}
|
||||
periodAnchor={state.accountsPeriodAnchor}
|
||||
sinceCreationDate={earliestSnapshotDate}
|
||||
latentGainByAccount={state.latentGainByAccount}
|
||||
latentGainRollup={state.latentGainRollup}
|
||||
vehicleLabels={vehicleLabels}
|
||||
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
||||
onLinkTransfers={(acc) => setLinkTarget(acc)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ import {
|
|||
listHoldingsBySnapshotLine,
|
||||
getHoldingsForLatestSnapshot,
|
||||
computeUnrealizedGain,
|
||||
getAccountLatentGainByLine,
|
||||
rollupLatentGain,
|
||||
type AccountUnrealizedGain,
|
||||
PRICED_VALUE_TOLERANCE,
|
||||
BalanceServiceError,
|
||||
getSnapshotTotalsByDate,
|
||||
|
|
@ -1703,6 +1706,9 @@ describe("getAccountsLatestSnapshot", () => {
|
|||
// LEFT JOIN-equivalent: scalar subquery so accounts with no lines still surface.
|
||||
expect(sql).toContain("ORDER BY s.snapshot_date DESC");
|
||||
expect(sql).toContain("LIMIT 1");
|
||||
// #216: account kind + latest line id surfaced for the drill-down.
|
||||
expect(sql).toContain("a.kind AS kind");
|
||||
expect(sql).toContain("AS latest_snapshot_line_id");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2791,3 +2797,120 @@ describe("computeUnrealizedGain", () => {
|
|||
expect(r.holdings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAccountLatentGainByLine", () => {
|
||||
it("reads the line's holdings then folds them through computeUnrealizedGain", async () => {
|
||||
mockSelect.mockResolvedValueOnce([
|
||||
{
|
||||
security_id: 1,
|
||||
value: 120,
|
||||
book_cost: 100,
|
||||
security_symbol: "AAPL",
|
||||
},
|
||||
{
|
||||
security_id: 2,
|
||||
value: 300,
|
||||
book_cost: null,
|
||||
security_symbol: "MSFT",
|
||||
},
|
||||
]);
|
||||
const r = await getAccountLatentGainByLine(42);
|
||||
// It reads holdings of the supplied line id.
|
||||
expect(mockSelect.mock.calls[0][1]).toEqual([42]);
|
||||
// Aggregate mirrors computeUnrealizedGain (NULL book_cost excluded, flagged).
|
||||
expect(r.total_value).toBe(420);
|
||||
expect(r.total_gain).toBe(20);
|
||||
expect(r.has_unknown_book_cost).toBe(true);
|
||||
// Symbol from the security join is carried onto the per-holding rows.
|
||||
expect(r.holdings[0].symbol).toBe("AAPL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollupLatentGain", () => {
|
||||
// A small AccountUnrealizedGain factory keeps the cases readable.
|
||||
const g = (
|
||||
over: Partial<AccountUnrealizedGain>
|
||||
): AccountUnrealizedGain => ({
|
||||
total_value: 0,
|
||||
total_book_cost: 0,
|
||||
total_gain: 0,
|
||||
total_gain_pct: null,
|
||||
has_unknown_book_cost: false,
|
||||
holdings: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
it("groups by asset class and by envelope, and sums a grand total", () => {
|
||||
const r = rollupLatentGain([
|
||||
{
|
||||
category_key: "stock",
|
||||
vehicle_type: "tfsa",
|
||||
gain: g({ total_value: 120, total_book_cost: 100, total_gain: 20 }),
|
||||
},
|
||||
{
|
||||
category_key: "stock",
|
||||
vehicle_type: "rrsp",
|
||||
gain: g({ total_value: 80, total_book_cost: 100, total_gain: -20 }),
|
||||
},
|
||||
{
|
||||
category_key: "crypto",
|
||||
vehicle_type: "tfsa",
|
||||
gain: g({ total_value: 50, total_book_cost: 40, total_gain: 10 }),
|
||||
},
|
||||
]);
|
||||
|
||||
// Asset-class buckets: stock merges the two accounts, crypto stands alone.
|
||||
const stock = r.byClass.find((b) => b.group_key === "stock")!;
|
||||
expect(stock.total_value).toBe(200);
|
||||
expect(stock.total_gain).toBe(0);
|
||||
expect(stock.total_gain_pct).toBe(0); // 0 / 200
|
||||
const crypto = r.byClass.find((b) => b.group_key === "crypto")!;
|
||||
expect(crypto.total_gain_pct).toBeCloseTo(0.25, 5); // 10 / 40
|
||||
|
||||
// Envelope buckets: tfsa merges stock+crypto, rrsp stands alone.
|
||||
const tfsa = r.byVehicle.find((b) => b.group_key === "tfsa")!;
|
||||
expect(tfsa.total_value).toBe(170);
|
||||
expect(tfsa.total_gain).toBe(30);
|
||||
expect(tfsa.total_gain_pct).toBeCloseTo(30 / 140, 5);
|
||||
|
||||
// Grand total across every account.
|
||||
expect(r.grandTotal.total_value).toBe(250);
|
||||
expect(r.grandTotal.total_gain).toBe(10);
|
||||
expect(r.grandTotal.total_gain_pct).toBeCloseTo(10 / 240, 5);
|
||||
expect(r.grandTotal.has_unknown_book_cost).toBe(false);
|
||||
});
|
||||
|
||||
it("buckets a NULL vehicle_type under the 'none' envelope key", () => {
|
||||
const r = rollupLatentGain([
|
||||
{
|
||||
category_key: "other",
|
||||
vehicle_type: null,
|
||||
gain: g({ total_value: 10, total_book_cost: 10, total_gain: 0 }),
|
||||
},
|
||||
]);
|
||||
expect(r.byVehicle.map((b) => b.group_key)).toEqual(["none"]);
|
||||
});
|
||||
|
||||
it("a bucket with no known book_cost yields a null % and flags unknown", () => {
|
||||
const r = rollupLatentGain([
|
||||
{
|
||||
category_key: "stock",
|
||||
vehicle_type: "tfsa",
|
||||
// All book_cost unknown ⇒ total_book_cost 0, gain 0, flagged.
|
||||
gain: g({ total_value: 500, has_unknown_book_cost: true }),
|
||||
},
|
||||
]);
|
||||
expect(r.byClass[0].total_gain_pct).toBeNull();
|
||||
expect(r.byClass[0].has_unknown_book_cost).toBe(true);
|
||||
expect(r.grandTotal.total_gain_pct).toBeNull();
|
||||
expect(r.grandTotal.has_unknown_book_cost).toBe(true);
|
||||
});
|
||||
|
||||
it("empty input ⇒ empty buckets and a zeroed grand total", () => {
|
||||
const r = rollupLatentGain([]);
|
||||
expect(r.byClass).toEqual([]);
|
||||
expect(r.byVehicle).toEqual([]);
|
||||
expect(r.grandTotal.total_value).toBe(0);
|
||||
expect(r.grandTotal.total_gain_pct).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1874,6 +1874,13 @@ export interface AccountLatestSnapshot {
|
|||
category_kind: BalanceCategoryKind;
|
||||
/** Mirror of `balance_categories.custom_label` — drives renderCategoryLabel. */
|
||||
category_custom_label?: string | null;
|
||||
/**
|
||||
* Entry mode of the account itself (`balance_accounts.kind`, migration v15).
|
||||
* 'detailed' accounts can drill down to per-security holdings; 'simple' carry
|
||||
* a single value. Surfaced so the accounts table knows which rows expand
|
||||
* (Issue #216). Authoritative over `category_kind` for dispatch.
|
||||
*/
|
||||
kind: BalanceAccountKind;
|
||||
/**
|
||||
* Fiscal envelope of the account (`vehicle_type`), or NULL when none.
|
||||
* Surfaced for the "par enveloppe" axis groupings (Issue #204).
|
||||
|
|
@ -1883,6 +1890,13 @@ export interface AccountLatestSnapshot {
|
|||
latest_snapshot_date: string | null;
|
||||
/** Value at that snapshot, or null if the account has no snapshot lines. */
|
||||
latest_value: number | null;
|
||||
/**
|
||||
* Id of the snapshot LINE backing `latest_value` (the row in
|
||||
* `balance_snapshot_lines`), or null if the account has no snapshot lines.
|
||||
* Lets the per-security drill-down read holdings via
|
||||
* `listHoldingsBySnapshotLine` without a second resolve (Issue #216).
|
||||
*/
|
||||
latest_snapshot_line_id: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1908,6 +1922,7 @@ export async function getAccountsLatestSnapshot(): Promise<
|
|||
c.i18n_key AS category_i18n_key,
|
||||
c.kind AS category_kind,
|
||||
c.custom_label AS category_custom_label,
|
||||
a.kind AS kind,
|
||||
a.vehicle_type AS vehicle_type,
|
||||
(SELECT s.snapshot_date
|
||||
FROM balance_snapshot_lines l
|
||||
|
|
@ -1920,7 +1935,13 @@ export async function getAccountsLatestSnapshot(): Promise<
|
|||
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||||
WHERE l.account_id = a.id
|
||||
ORDER BY s.snapshot_date DESC
|
||||
LIMIT 1) AS latest_value
|
||||
LIMIT 1) AS latest_value,
|
||||
(SELECT l.id
|
||||
FROM balance_snapshot_lines l
|
||||
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||||
WHERE l.account_id = a.id
|
||||
ORDER BY s.snapshot_date DESC
|
||||
LIMIT 1) AS latest_snapshot_line_id
|
||||
FROM balance_accounts a
|
||||
INNER JOIN balance_categories c ON c.id = a.balance_category_id
|
||||
WHERE a.is_active = 1 AND a.archived_at IS NULL
|
||||
|
|
@ -2155,6 +2176,150 @@ export function computeUnrealizedGain(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Latent gain of a single detailed account, resolved from its latest snapshot
|
||||
* line. Reads that line's holdings (joined with their security for display) and
|
||||
* folds them through `computeUnrealizedGain` — so the N/A / book_cost guards are
|
||||
* shared with the per-holding path (Issue #216). Returns the per-holding
|
||||
* breakdown plus the account-level aggregate; `lineId` is the
|
||||
* `latest_snapshot_line_id` carried by `AccountLatestSnapshot`.
|
||||
*/
|
||||
export async function getAccountLatentGainByLine(
|
||||
lineId: number
|
||||
): Promise<AccountUnrealizedGain> {
|
||||
const holdings = await listHoldingsBySnapshotLine(lineId);
|
||||
return computeUnrealizedGain(
|
||||
holdings.map((h) => ({
|
||||
security_id: h.security_id,
|
||||
value: h.value,
|
||||
book_cost: h.book_cost,
|
||||
symbol: h.security_symbol,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/** A single bucket of the latent-gain rollup (one asset class or one envelope). */
|
||||
export interface LatentGainBucket {
|
||||
/**
|
||||
* Grouping key: the category `key` for asset-class buckets, or the
|
||||
* `vehicle_type` code (with `VEHICLE_NONE_BUCKET` for NULL) for envelope
|
||||
* buckets. Lets the UI resolve a translated label without re-querying.
|
||||
*/
|
||||
group_key: string;
|
||||
/** SUM(value) across the detailed accounts in this bucket. */
|
||||
total_value: number;
|
||||
/** SUM(value − book_cost) over holdings WITH a known book_cost. */
|
||||
total_gain: number;
|
||||
/** total_gain / SUM(book_cost), or null when no known book_cost contributes. */
|
||||
total_gain_pct: number | null;
|
||||
/** True when ≥1 holding in the bucket has a NULL book_cost (excluded). */
|
||||
has_unknown_book_cost: boolean;
|
||||
}
|
||||
|
||||
/** Per-account input row for `rollupLatentGain` (decouples the SQL from the math). */
|
||||
export interface AccountLatentGainInput {
|
||||
category_key: string;
|
||||
vehicle_type?: BalanceVehicleType | null;
|
||||
gain: AccountUnrealizedGain;
|
||||
}
|
||||
|
||||
/** Output of `rollupLatentGain`: aggregates by asset class, by envelope, grand total. */
|
||||
export interface LatentGainRollup {
|
||||
byClass: LatentGainBucket[];
|
||||
byVehicle: LatentGainBucket[];
|
||||
/** Grand total across every detailed account (the BalanceOverviewCard figure). */
|
||||
grandTotal: {
|
||||
total_value: number;
|
||||
total_book_cost: number;
|
||||
total_gain: number;
|
||||
total_gain_pct: number | null;
|
||||
has_unknown_book_cost: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure rollup of per-account latent gains into asset-class buckets, envelope
|
||||
* buckets, and a grand total (Issue #216). No DB access — feed it the per-account
|
||||
* `AccountUnrealizedGain`s (from `getAccountLatentGainByLine`) tagged with their
|
||||
* `category_key` and `vehicle_type`, so it's trivially unit-testable.
|
||||
*
|
||||
* Accumulates `total_book_cost` per bucket internally to derive a sound `%`
|
||||
* (gain / known-book_cost), mirroring `computeUnrealizedGain`'s aggregate guard:
|
||||
* a bucket with no known book_cost yields `total_gain_pct = null`. NULL-book_cost
|
||||
* holdings are already excluded from each account's `total_book_cost`/`total_gain`
|
||||
* and surfaced via `has_unknown_book_cost`, which is OR-ed up per bucket.
|
||||
* Buckets are emitted in first-seen order (callers sort for display).
|
||||
*/
|
||||
export function rollupLatentGain(
|
||||
accounts: AccountLatentGainInput[]
|
||||
): LatentGainRollup {
|
||||
interface Acc {
|
||||
group_key: string;
|
||||
total_value: number;
|
||||
total_book_cost: number;
|
||||
total_gain: number;
|
||||
has_unknown_book_cost: boolean;
|
||||
}
|
||||
const classMap = new Map<string, Acc>();
|
||||
const vehicleMap = new Map<string, Acc>();
|
||||
const grand: Acc = {
|
||||
group_key: "",
|
||||
total_value: 0,
|
||||
total_book_cost: 0,
|
||||
total_gain: 0,
|
||||
has_unknown_book_cost: false,
|
||||
};
|
||||
|
||||
const fold = (map: Map<string, Acc>, key: string, g: AccountUnrealizedGain) => {
|
||||
let bucket = map.get(key);
|
||||
if (!bucket) {
|
||||
bucket = {
|
||||
group_key: key,
|
||||
total_value: 0,
|
||||
total_book_cost: 0,
|
||||
total_gain: 0,
|
||||
has_unknown_book_cost: false,
|
||||
};
|
||||
map.set(key, bucket);
|
||||
}
|
||||
bucket.total_value += g.total_value;
|
||||
bucket.total_book_cost += g.total_book_cost;
|
||||
bucket.total_gain += g.total_gain;
|
||||
bucket.has_unknown_book_cost ||= g.has_unknown_book_cost;
|
||||
};
|
||||
|
||||
for (const a of accounts) {
|
||||
const vehKey = a.vehicle_type ?? VEHICLE_NONE_BUCKET;
|
||||
fold(classMap, a.category_key, a.gain);
|
||||
fold(vehicleMap, vehKey, a.gain);
|
||||
grand.total_value += a.gain.total_value;
|
||||
grand.total_book_cost += a.gain.total_book_cost;
|
||||
grand.total_gain += a.gain.total_gain;
|
||||
grand.has_unknown_book_cost ||= a.gain.has_unknown_book_cost;
|
||||
}
|
||||
|
||||
const toBucket = (b: Acc): LatentGainBucket => ({
|
||||
group_key: b.group_key,
|
||||
total_value: b.total_value,
|
||||
total_gain: b.total_gain,
|
||||
total_gain_pct: b.total_book_cost === 0 ? null : b.total_gain / b.total_book_cost,
|
||||
has_unknown_book_cost: b.has_unknown_book_cost,
|
||||
});
|
||||
|
||||
return {
|
||||
byClass: [...classMap.values()].map(toBucket),
|
||||
byVehicle: [...vehicleMap.values()].map(toBucket),
|
||||
grandTotal: {
|
||||
total_value: grand.total_value,
|
||||
total_book_cost: grand.total_book_cost,
|
||||
total_gain: grand.total_gain,
|
||||
total_gain_pct:
|
||||
grand.total_book_cost === 0 ? null : grand.total_gain / grand.total_book_cost,
|
||||
has_unknown_book_cost: grand.has_unknown_book_cost,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Returns + transfers (Issue #142 / Bilan #4)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue