diff --git a/lang/es.json b/lang/es.json index f39aaa0f..2ec3e0c2 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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", diff --git a/resources/js/components/dashboard/net-worth-chart.tsx b/resources/js/components/dashboard/net-worth-chart.tsx index b96a4a6a..ab4c25e1 100644 --- a/resources/js/components/dashboard/net-worth-chart.tsx +++ b/resources/js/components/dashboard/net-worth-chart.tsx @@ -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 ( @@ -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 } diff --git a/resources/js/components/ui/chart.tsx b/resources/js/components/ui/chart.tsx index 886200cd..0814ade1 100644 --- a/resources/js/components/ui/chart.tsx +++ b/resources/js/components/ui/chart.tsx @@ -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; } +/** + * 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; 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}
- {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 (
diff --git a/resources/js/components/ui/stacked-area-chart.tsx b/resources/js/components/ui/stacked-area-chart.tsx index 4c2cd436..ab797a42 100644 --- a/resources/js/components/ui/stacked-area-chart.tsx +++ b/resources/js/components/ui/stacked-area-chart.tsx @@ -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> { className?: string; showLegend?: boolean; minBarWidth?: number; - netWorthMode?: { liabilityTypeLabel: string; liabilityDotColor?: string }; + netWorthMode?: NetWorthMode; } export function StackedAreaChart>({ @@ -119,6 +120,13 @@ export function StackedAreaChart>({ axisLine={false} tickFormatter={xAxisFormatter} /> + {netWorthMode?.deficitKey && ( + + )} >({ /> ); })} + {netWorthMode?.deficitKey && ( + + )}
diff --git a/resources/js/components/ui/stacked-bar-chart.test.tsx b/resources/js/components/ui/stacked-bar-chart.test.tsx index 2d4e7d2a..14f5166d 100644 --- a/resources/js/components/ui/stacked-bar-chart.test.tsx +++ b/resources/js/components/ui/stacked-bar-chart.test.tsx @@ -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( + + + , + ); + + 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'); + }); }); diff --git a/resources/js/components/ui/stacked-bar-chart.tsx b/resources/js/components/ui/stacked-bar-chart.tsx index d51583ba..34a6d76b 100644 --- a/resources/js/components/ui/stacked-bar-chart.tsx +++ b/resources/js/components/ui/stacked-bar-chart.tsx @@ -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; 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 ( > { className?: string; showLegend?: boolean; minBarWidth?: number; - netWorthMode?: { liabilityTypeLabel: string; liabilityDotColor?: string }; + netWorthMode?: NetWorthMode; } export function StackedBarChart>({ @@ -165,6 +177,13 @@ export function StackedBarChart>({ 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>({ {...props} dataKey={key} dataKeys={dataKeys} + flatBottom={hasDeficit} /> ); @@ -190,7 +210,7 @@ export function StackedBarChart>({ (props: BarShapeProps) => React.ReactElement | null >, ); - }, [dataKeys]); + }, [dataKeys, hasDeficit]); return (
>({ axisLine={false} tickFormatter={xAxisFormatter} /> + {netWorthMode?.deficitKey && ( + + )} } content={ @@ -234,6 +261,21 @@ export function StackedBarChart>({ shape={shapeRenderers[key]} /> ))} + {netWorthMode?.deficitKey && ( + + )}
diff --git a/resources/js/lib/chart-calculations.test.ts b/resources/js/lib/chart-calculations.test.ts index f9fbfab9..6bfcbd45 100644 --- a/resources/js/lib/chart-calculations.test.ts +++ b/resources/js/lib/chart-calculations.test.ts @@ -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); diff --git a/resources/js/lib/chart-calculations.ts b/resources/js/lib/chart-calculations.ts index a9042859..6edf138c 100644 --- a/resources/js/lib/chart-calculations.ts +++ b/resources/js/lib/chart-calculations.ts @@ -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 */