fix(dashboard): render negative net worth as a downward bar (#722)
## Problem
On the dashboard **Net Worth Evolution** monthly bar chart, users with a
large negative net worth (lots of debt) saw an **empty chart** — only
the x-axis month labels rendered, no bars.
Root cause: the chart scales each asset segment so the stacked bar
height equals net worth. For negative net worth the scale factor was
clamped to zero:
```ts
const scaleFactor = hasLiabs && totalAssets > 0
? Math.max(0, netWorth / totalAssets) // negative net worth -> 0 -> every bar collapses
: 1;
```
A stack of positive asset segments simply can't represent a negative
total, so everything collapsed to zero height.
## Fix
- Extract the scaling into a tested pure helper
`computeNetWorthBarScaling`.
- When net worth is **negative**, hide the (meaningless) scaled asset
segments and draw the deficit as a **single downward bar** from a **zero
baseline** (dashed reference line).
- **Positive** periods still stack assets upward. Both directions round
only at their far end and meet the zero line flush, so upward and
downward bars look symmetric.
- Extend the same handling to the **daily area chart**, which shared the
bug through the same scaled dataset.
- Share a single `NetWorthMode` type across the bar/area charts and
tooltip (was duplicated in three places).
- Translate the tooltip **Net Worth / Total** labels (were hardcoded
English; added `Net Worth` to `lang/es.json`).
- Render the chart for **liability-only** users (a debtor with just a
loan) instead of the "No account data available" empty state.
## Tests
- Unit tests for `computeNetWorthBarScaling`: no-liabilities, positive,
negative-with-assets, liabilities-only, net-worth-exactly-zero, and
no-assets cases.
- `bun run test`, `prettier --check`, `eslint` all green on the touched
files.
## QA
Verified in the running app (Playwright) with two seeded users:
- **Heavily indebted** (net worth −56.250,00 €): the chart now renders
red downward bars instead of an empty area.
- **Net worth crossing zero**: negative months draw downward, positive
months stack upward, sharing the zero baseline; tooltip shows
per-account balances + the (now translated) "Patrimonio Neto" total.
## Demo
https://github.com/user-attachments/assets/7a9b167f-c037-4c82-8b68-f32932be99fe
## Screenshots
**Before** — empty chart, only x-axis labels:
<!-- PLACEHOLDER: before screenshot -->
<img width="1440" height="900" alt="net-worth-before"
src="https://github.com/user-attachments/assets/3bf9b3e3-ae75-4230-bca7-e7246a9a35d9"
/>
**After** — negative net worth as downward bars:
<img width="1440" height="900" alt="net-worth-after-tooltip"
src="https://github.com/user-attachments/assets/85302f0a-e427-4cbf-baba-5b1414049e9f"
/>
<img width="1440" height="900" alt="net-worth-after-mixed"
src="https://github.com/user-attachments/assets/41f08331-f055-4245-beeb-8d071c0d5d6d"
/>
<img width="1440" height="900" alt="net-worth-after-negative"
src="https://github.com/user-attachments/assets/c8610b03-dcff-46de-8987-d47c477ddd85"
/>
This commit is contained in:
parent
9e1aedcccd
commit
50d0fccd17
|
|
@ -1115,6 +1115,7 @@
|
|||
"Need help? Join our Discord": "¿Necesitas ayuda? Únete a nuestro Discord",
|
||||
"Net": "Neto",
|
||||
"Net Cashflow": "Flujo Neto",
|
||||
"Net Worth": "Patrimonio Neto",
|
||||
"Net Worth Evolution": "Evolución del Patrimonio Neto",
|
||||
"Neutral": "Neutro",
|
||||
"Never": "Nunca",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { useLocale } from '@/hooks/use-locale';
|
|||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import {
|
||||
AccountInfo,
|
||||
computeNetWorthBarScaling,
|
||||
isLiabilityType,
|
||||
netWorthContribution,
|
||||
} from '@/lib/chart-calculations';
|
||||
|
|
@ -47,6 +48,13 @@ interface NetWorthChartProps {
|
|||
|
||||
const DAILY_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Synthetic data key holding a period's negative net worth so the bar chart
|
||||
* can draw it as a single downward bar (assets can't stack to a negative
|
||||
* total).
|
||||
*/
|
||||
const NET_WORTH_DEFICIT_KEY = '__net_worth_deficit';
|
||||
|
||||
interface TrendData {
|
||||
percentage: number;
|
||||
previousAmount: number;
|
||||
|
|
@ -332,10 +340,17 @@ export function NetWorthChart({
|
|||
});
|
||||
|
||||
const netWorth = totalAssets - totalLiabilities;
|
||||
const scaleFactor =
|
||||
hasLiabs && totalAssets > 0
|
||||
? Math.max(0, netWorth / totalAssets)
|
||||
: 1;
|
||||
const { scaleFactor, deficit } = computeNetWorthBarScaling(
|
||||
totalAssets,
|
||||
totalLiabilities,
|
||||
hasLiabs,
|
||||
);
|
||||
|
||||
// Negative net worth can't be a stack of positive asset segments,
|
||||
// so draw it as a single downward bar from the zero baseline.
|
||||
if (deficit !== null) {
|
||||
newPoint[NET_WORTH_DEFICIT_KEY] = deficit;
|
||||
}
|
||||
|
||||
// Asset values: scaled for rendering, original for tooltip
|
||||
chartAccountIds.forEach((id) => {
|
||||
|
|
@ -502,7 +517,9 @@ export function NetWorthChart({
|
|||
);
|
||||
}
|
||||
|
||||
if (dataKeys.length === 0) {
|
||||
// Render the chart when there are asset segments to stack, or liabilities
|
||||
// to draw as a downward deficit — a debtor with only a loan still has data.
|
||||
if (dataKeys.length === 0 && !hasLiabilities) {
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
|
|
@ -616,6 +633,7 @@ export function NetWorthChart({
|
|||
? {
|
||||
liabilityTypeLabel: __('Loan'),
|
||||
liabilityDotColor,
|
||||
deficitKey: NET_WORTH_DEFICIT_KEY,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
|
@ -638,6 +656,7 @@ export function NetWorthChart({
|
|||
? {
|
||||
liabilityTypeLabel: __('Loan'),
|
||||
liabilityDotColor,
|
||||
deficitKey: NET_WORTH_DEFICIT_KEY,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { usePrivacyMode } from '@/contexts/privacy-mode-context';
|
|||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatCurrency } from '@/utils/currency';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
||||
const THEMES = { light: '', dark: '.dark' } as const;
|
||||
|
||||
|
|
@ -215,6 +216,22 @@ interface TooltipPayloadItem {
|
|||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Net-worth chart mode shared by the stacked bar/area charts and this tooltip.
|
||||
* When set, the tooltip shows liability rows and a net-worth total instead of a
|
||||
* plain sum, and the charts can draw a negative net worth as a downward series.
|
||||
*/
|
||||
export interface NetWorthMode {
|
||||
liabilityTypeLabel: string;
|
||||
liabilityDotColor?: string;
|
||||
/**
|
||||
* Synthetic data key holding a period's negative net worth. The charts draw
|
||||
* it as a single downward bar/area; the tooltip hides it from the per-account
|
||||
* rows since the negative total already shows in the net-worth row.
|
||||
*/
|
||||
deficitKey?: string;
|
||||
}
|
||||
|
||||
interface ChartTooltipContentProps {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayloadItem[];
|
||||
|
|
@ -241,10 +258,7 @@ interface ChartTooltipContentProps {
|
|||
accountCurrencies?: Record<string, string>;
|
||||
displayCurrency?: string;
|
||||
/** When set, tooltip shows liability rows and net-worth total instead of simple sum. */
|
||||
netWorthMode?: {
|
||||
liabilityTypeLabel: string;
|
||||
liabilityDotColor?: string;
|
||||
};
|
||||
netWorthMode?: NetWorthMode;
|
||||
}
|
||||
|
||||
function formatCurrencyWithCode(
|
||||
|
|
@ -327,7 +341,9 @@ const ChartTooltipContent = React.forwardRef<
|
|||
return null;
|
||||
}
|
||||
|
||||
// In net worth mode, use pre-computed net worth from data point
|
||||
// In net worth mode, use pre-computed net worth from data point.
|
||||
// This returns before the raw-payload sums below, so the synthetic
|
||||
// deficit series never gets double-counted into a currency total.
|
||||
if (netWorthMode && displayCurrency) {
|
||||
const netWorth = payload[0]?.payload?.__net_worth as number | undefined;
|
||||
if (netWorth !== undefined) {
|
||||
|
|
@ -363,7 +379,13 @@ const ChartTooltipContent = React.forwardRef<
|
|||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
||||
// The synthetic deficit series is rendering-only; the negative net
|
||||
// worth already shows in the total row, so drop it from the item list.
|
||||
const itemPayload = netWorthMode?.deficitKey
|
||||
? payload.filter((item) => item.dataKey !== netWorthMode.deficitKey)
|
||||
: payload;
|
||||
|
||||
const nestLabel = itemPayload.length === 1 && indicator !== 'dot';
|
||||
const hasMultipleCurrencies =
|
||||
currencyTotals && currencyTotals.length > 1;
|
||||
|
||||
|
|
@ -378,7 +400,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)] gap-1.5">
|
||||
{payload.map(
|
||||
{itemPayload.map(
|
||||
(item: TooltipPayloadItem, index: number) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(
|
||||
|
|
@ -479,11 +501,13 @@ const ChartTooltipContent = React.forwardRef<
|
|||
? (JSON.parse(liabilitiesJson) as Array<{ name: string; amount: number }>)
|
||||
: [];
|
||||
const hasLiabilities = typeof liabilitiesTotal === 'number' && liabilitiesTotal > 0;
|
||||
const showTotalSection = payload.length > 1 || hasLiabilities;
|
||||
const showTotalSection = itemPayload.length > 1 || hasLiabilities;
|
||||
|
||||
if (!showTotalSection) return null;
|
||||
|
||||
const totalLabel = hasLiabilities ? 'Net Worth' : 'Total';
|
||||
const totalLabel = hasLiabilities
|
||||
? __('Net Worth')
|
||||
: __('Total');
|
||||
|
||||
return (
|
||||
<div className="border-border/50 flex flex-col gap-1 border-t pt-1.5 min-w-0">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { Area, AreaChart, XAxis } from 'recharts';
|
||||
import { Area, AreaChart, ReferenceLine, XAxis } from 'recharts';
|
||||
|
||||
import {
|
||||
ChartConfig,
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type NetWorthMode,
|
||||
} from '@/components/ui/chart';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
|
@ -36,7 +37,7 @@ export interface StackedAreaChartProps<T extends Record<string, unknown>> {
|
|||
className?: string;
|
||||
showLegend?: boolean;
|
||||
minBarWidth?: number;
|
||||
netWorthMode?: { liabilityTypeLabel: string; liabilityDotColor?: string };
|
||||
netWorthMode?: NetWorthMode;
|
||||
}
|
||||
|
||||
export function StackedAreaChart<T extends Record<string, unknown>>({
|
||||
|
|
@ -119,6 +120,13 @@ export function StackedAreaChart<T extends Record<string, unknown>>({
|
|||
axisLine={false}
|
||||
tickFormatter={xAxisFormatter}
|
||||
/>
|
||||
{netWorthMode?.deficitKey && (
|
||||
<ReferenceLine
|
||||
y={0}
|
||||
stroke="var(--color-border)"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
)}
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
|
|
@ -151,6 +159,26 @@ export function StackedAreaChart<T extends Record<string, unknown>>({
|
|||
/>
|
||||
);
|
||||
})}
|
||||
{netWorthMode?.deficitKey && (
|
||||
<Area
|
||||
key={netWorthMode.deficitKey}
|
||||
dataKey={netWorthMode.deficitKey}
|
||||
stackId="stack"
|
||||
type="monotone"
|
||||
fill={
|
||||
netWorthMode.liabilityDotColor ??
|
||||
'var(--color-destructive)'
|
||||
}
|
||||
stroke={
|
||||
netWorthMode.liabilityDotColor ??
|
||||
'var(--color-destructive)'
|
||||
}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
fillOpacity={0.15}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -48,4 +48,29 @@ describe('StackedBarShape', () => {
|
|||
expect(path?.getAttribute('d')).toContain('M 1 0');
|
||||
expect(path?.getAttribute('d')).toContain('H 19');
|
||||
});
|
||||
|
||||
it('keeps the bottom edge square when flatBottom is set', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<StackedBarShape
|
||||
x={0}
|
||||
y={0}
|
||||
width={20}
|
||||
height={10}
|
||||
fill="var(--color-chart-2)"
|
||||
payload={{ asset: 10 }}
|
||||
dataKey="asset"
|
||||
dataKeys={['asset']}
|
||||
flatBottom
|
||||
/>
|
||||
</svg>,
|
||||
);
|
||||
|
||||
const d = container.querySelector('path')?.getAttribute('d');
|
||||
|
||||
// Rounded at the top (far end), square at the zero baseline.
|
||||
expect(d).toContain('Q 20 0 20 4');
|
||||
expect(d).toContain('V 10 H 0');
|
||||
expect(d).not.toContain('Q 20 10');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { Bar, BarChart, Rectangle, XAxis, type BarShapeProps } from 'recharts';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Rectangle,
|
||||
ReferenceLine,
|
||||
XAxis,
|
||||
type BarShapeProps,
|
||||
} from 'recharts';
|
||||
|
||||
import {
|
||||
ChartConfig,
|
||||
|
|
@ -8,6 +15,7 @@ import {
|
|||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type NetWorthMode,
|
||||
} from '@/components/ui/chart';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
|
@ -35,6 +43,47 @@ interface StackedBarShapeProps {
|
|||
payload?: Record<string, unknown>;
|
||||
dataKey: string;
|
||||
dataKeys: string[];
|
||||
/**
|
||||
* Keep the bottom edge square even for the bottom-most segment. Used when
|
||||
* the chart has a zero baseline shared with downward deficit bars, so the
|
||||
* positive stack meets the axis flush (rounded only at its far/top end).
|
||||
*/
|
||||
flatBottom?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a rounded-rectangle path, rounding only the requested corners.
|
||||
* Traversal matches the original per-corner variants so an all-rounded or
|
||||
* top/bottom-only bar produces an identical `d` string.
|
||||
*/
|
||||
function roundedBarPath(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
roundTop: boolean,
|
||||
roundBottom: boolean,
|
||||
): string {
|
||||
const rTop = roundTop ? radius : 0;
|
||||
const rBottom = roundBottom ? radius : 0;
|
||||
|
||||
return [
|
||||
`M ${x + rTop} ${y}`,
|
||||
`H ${x + width - rTop}`,
|
||||
rTop ? `Q ${x + width} ${y} ${x + width} ${y + rTop}` : '',
|
||||
`V ${y + height - rBottom}`,
|
||||
rBottom
|
||||
? `Q ${x + width} ${y + height} ${x + width - rBottom} ${y + height}`
|
||||
: '',
|
||||
`H ${x + rBottom}`,
|
||||
rBottom ? `Q ${x} ${y + height} ${x} ${y + height - rBottom}` : '',
|
||||
`V ${y + rTop}`,
|
||||
rTop ? `Q ${x} ${y} ${x + rTop} ${y}` : '',
|
||||
'Z',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function StackedBarShape({
|
||||
|
|
@ -46,6 +95,7 @@ export function StackedBarShape({
|
|||
payload,
|
||||
dataKey,
|
||||
dataKeys,
|
||||
flatBottom = false,
|
||||
}: StackedBarShapeProps) {
|
||||
if (height <= 0) return null;
|
||||
|
||||
|
|
@ -59,53 +109,15 @@ export function StackedBarShape({
|
|||
const isFirstVisible = visibleKeys[0] === dataKey;
|
||||
const isLastVisible = visibleKeys[visibleKeys.length - 1] === dataKey;
|
||||
|
||||
let path: string;
|
||||
|
||||
if (isFirstVisible && isLastVisible) {
|
||||
path = `
|
||||
M ${x + radius} ${y}
|
||||
H ${x + width - radius}
|
||||
Q ${x + width} ${y} ${x + width} ${y + radius}
|
||||
V ${y + height - radius}
|
||||
Q ${x + width} ${y + height} ${x + width - radius} ${y + height}
|
||||
H ${x + radius}
|
||||
Q ${x} ${y + height} ${x} ${y + height - radius}
|
||||
V ${y + radius}
|
||||
Q ${x} ${y} ${x + radius} ${y}
|
||||
Z
|
||||
`;
|
||||
} else if (isLastVisible) {
|
||||
path = `
|
||||
M ${x + radius} ${y}
|
||||
H ${x + width - radius}
|
||||
Q ${x + width} ${y} ${x + width} ${y + radius}
|
||||
V ${y + height}
|
||||
H ${x}
|
||||
V ${y + radius}
|
||||
Q ${x} ${y} ${x + radius} ${y}
|
||||
Z
|
||||
`;
|
||||
} else if (isFirstVisible) {
|
||||
path = `
|
||||
M ${x} ${y}
|
||||
H ${x + width}
|
||||
V ${y + height - radius}
|
||||
Q ${x + width} ${y + height} ${x + width - radius} ${y + height}
|
||||
H ${x + radius}
|
||||
Q ${x} ${y + height} ${x} ${y + height - radius}
|
||||
V ${y}
|
||||
Z
|
||||
`;
|
||||
} else {
|
||||
path = `
|
||||
M ${x} ${y}
|
||||
H ${x + width}
|
||||
V ${y + height}
|
||||
H ${x}
|
||||
V ${y}
|
||||
Z
|
||||
`;
|
||||
}
|
||||
const path = roundedBarPath(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
radius,
|
||||
isLastVisible,
|
||||
isFirstVisible && !flatBottom,
|
||||
);
|
||||
|
||||
return (
|
||||
<path
|
||||
|
|
@ -134,7 +146,7 @@ export interface StackedBarChartProps<T extends Record<string, unknown>> {
|
|||
className?: string;
|
||||
showLegend?: boolean;
|
||||
minBarWidth?: number;
|
||||
netWorthMode?: { liabilityTypeLabel: string; liabilityDotColor?: string };
|
||||
netWorthMode?: NetWorthMode;
|
||||
}
|
||||
|
||||
export function StackedBarChart<T extends Record<string, unknown>>({
|
||||
|
|
@ -165,6 +177,13 @@ export function StackedBarChart<T extends Record<string, unknown>>({
|
|||
|
||||
const minChartWidth = data.length * minBarWidth;
|
||||
|
||||
// When downward deficit bars share the zero baseline, keep the positive
|
||||
// stack square at the axis so both directions round only at their far end.
|
||||
const deficitKey = netWorthMode?.deficitKey;
|
||||
const hasDeficit = deficitKey
|
||||
? data.some((point) => typeof point[deficitKey] === 'number')
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollLeft =
|
||||
|
|
@ -180,6 +199,7 @@ export function StackedBarChart<T extends Record<string, unknown>>({
|
|||
{...props}
|
||||
dataKey={key}
|
||||
dataKeys={dataKeys}
|
||||
flatBottom={hasDeficit}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -190,7 +210,7 @@ export function StackedBarChart<T extends Record<string, unknown>>({
|
|||
(props: BarShapeProps) => React.ReactElement | null
|
||||
>,
|
||||
);
|
||||
}, [dataKeys]);
|
||||
}, [dataKeys, hasDeficit]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -210,6 +230,13 @@ export function StackedBarChart<T extends Record<string, unknown>>({
|
|||
axisLine={false}
|
||||
tickFormatter={xAxisFormatter}
|
||||
/>
|
||||
{netWorthMode?.deficitKey && (
|
||||
<ReferenceLine
|
||||
y={0}
|
||||
stroke="var(--color-border)"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
)}
|
||||
<ChartTooltip
|
||||
cursor={<CustomCursor />}
|
||||
content={
|
||||
|
|
@ -234,6 +261,21 @@ export function StackedBarChart<T extends Record<string, unknown>>({
|
|||
shape={shapeRenderers[key]}
|
||||
/>
|
||||
))}
|
||||
{netWorthMode?.deficitKey && (
|
||||
<Bar
|
||||
key={netWorthMode.deficitKey}
|
||||
dataKey={netWorthMode.deficitKey}
|
||||
stackId="stack"
|
||||
fill={
|
||||
netWorthMode.liabilityDotColor ??
|
||||
'var(--color-destructive)'
|
||||
}
|
||||
// Deficit values are negative, so Recharts flips the
|
||||
// radius vertically: top corners round the far (bottom)
|
||||
// end of the downward bar. Matches MoMChart.
|
||||
radius={[BORDER_RADIUS, BORDER_RADIUS, 0, 0]}
|
||||
/>
|
||||
)}
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
AccountInfo,
|
||||
computeDeltaSeries,
|
||||
computeMoMPercent,
|
||||
computeNetWorthBarScaling,
|
||||
computeNetWorthSeries,
|
||||
formatPercentValue,
|
||||
getAccountSign,
|
||||
|
|
@ -41,6 +42,50 @@ describe('isLiabilityType', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('computeNetWorthBarScaling', () => {
|
||||
it('leaves assets untouched when there are no liabilities', () => {
|
||||
expect(computeNetWorthBarScaling(100000, 0, false)).toEqual({
|
||||
scaleFactor: 1,
|
||||
deficit: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('scales assets down to net worth when positive', () => {
|
||||
expect(computeNetWorthBarScaling(100000, 40000, true)).toEqual({
|
||||
scaleFactor: 0.6,
|
||||
deficit: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides assets and reports the deficit when net worth is negative', () => {
|
||||
expect(computeNetWorthBarScaling(20000, 77604, true)).toEqual({
|
||||
scaleFactor: 0,
|
||||
deficit: -57604,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the full deficit when there are only liabilities', () => {
|
||||
expect(computeNetWorthBarScaling(0, 50000, true)).toEqual({
|
||||
scaleFactor: 0,
|
||||
deficit: -50000,
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses assets to zero when net worth is exactly zero', () => {
|
||||
expect(computeNetWorthBarScaling(50000, 50000, true)).toEqual({
|
||||
scaleFactor: 0,
|
||||
deficit: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a neutral factor when there are no assets to scale', () => {
|
||||
expect(computeNetWorthBarScaling(0, 0, true)).toEqual({
|
||||
scaleFactor: 1,
|
||||
deficit: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccountSign', () => {
|
||||
it('returns -1 for liabilities', () => {
|
||||
expect(getAccountSign('loan')).toBe(-1);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,49 @@ export function netWorthContribution(
|
|||
return isLiabilityType(type) ? -Math.abs(balance) : balance;
|
||||
}
|
||||
|
||||
export interface NetWorthBarScaling {
|
||||
/**
|
||||
* Factor applied to each asset segment so the stacked (upward) bar height
|
||||
* equals net worth. `0` when net worth is negative — assets are hidden and
|
||||
* the deficit is drawn instead.
|
||||
*/
|
||||
scaleFactor: number;
|
||||
/**
|
||||
* Signed net worth to draw as a single downward bar when it is negative,
|
||||
* or `null` when net worth is zero/positive (assets carry the height).
|
||||
*/
|
||||
deficit: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide how a period renders in the net worth bar chart.
|
||||
*
|
||||
* Positive net worth stacks assets upward, scaled so the total height equals
|
||||
* net worth. Negative net worth can't be shown as a stack of positive
|
||||
* segments, so assets are hidden (scaleFactor 0) and the deficit is drawn as a
|
||||
* single downward bar from the zero baseline.
|
||||
*/
|
||||
export function computeNetWorthBarScaling(
|
||||
totalAssets: number,
|
||||
totalLiabilities: number,
|
||||
hasLiabilities: boolean,
|
||||
): NetWorthBarScaling {
|
||||
if (!hasLiabilities) {
|
||||
return { scaleFactor: 1, deficit: null };
|
||||
}
|
||||
|
||||
const netWorth = totalAssets - totalLiabilities;
|
||||
|
||||
if (netWorth < 0) {
|
||||
return { scaleFactor: 0, deficit: netWorth };
|
||||
}
|
||||
|
||||
return {
|
||||
scaleFactor: totalAssets > 0 ? netWorth / totalAssets : 1,
|
||||
deficit: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Data point representing net worth for a month
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue