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:
Víctor Falcón 2026-07-22 11:32:18 +02:00 committed by GitHub
parent 9e1aedcccd
commit 50d0fccd17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 293 additions and 66 deletions

View File

@ -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",

View File

@ -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
}

View File

@ -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">

View File

@ -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>

View File

@ -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');
});
});

View File

@ -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>

View File

@ -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);

View File

@ -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
*/