From 5d2b9e931a67a79f3c0b3eaea38392f603d759fb Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 9 May 2026 21:55:30 +0000 Subject: [PATCH] frontend: stacked-area NW history chart on the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First end-to-end view: ECharts stacked area of net worth by account over the last 365 days, fed from GET /networth/history. - NetWorthChart component with empty-state fallback - Mocked ReactECharts in tests so they run without a DOM canvas - Dashboard now: headline NW + history chart + per-account cards Bundle grew to 467 KB gzipped — ECharts is heavy by design. Will tree-shake via echarts/core imports once the chart surface stabilises. Co-Authored-By: Claude Opus 4.7 --- .../src/components/NetWorthChart.test.tsx | 39 ++++++++++ frontend/src/components/NetWorthChart.tsx | 78 +++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 26 ++++++- 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/NetWorthChart.test.tsx create mode 100644 frontend/src/components/NetWorthChart.tsx diff --git a/frontend/src/components/NetWorthChart.test.tsx b/frontend/src/components/NetWorthChart.test.tsx new file mode 100644 index 0000000..161e1fe --- /dev/null +++ b/frontend/src/components/NetWorthChart.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; + +import { NetWorthChart } from './NetWorthChart'; + +vi.mock('echarts-for-react', () => ({ + default: ({ option }: { option: unknown }) => ( +
{JSON.stringify(option).length}
+ ), +})); + +describe('NetWorthChart', () => { + it('shows an empty-state when there are no points', () => { + render(); + expect(screen.getByText('No history yet.')).toBeInTheDocument(); + }); + + it('renders an ECharts option per account', () => { + render( + , + ); + expect(screen.getByTestId('chart')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/NetWorthChart.tsx b/frontend/src/components/NetWorthChart.tsx new file mode 100644 index 0000000..5ee013e --- /dev/null +++ b/frontend/src/components/NetWorthChart.tsx @@ -0,0 +1,78 @@ +/** + * Stacked area chart of net worth by account over time. + * + * Reads from `GET /networth/history` and groups by account name. Uses + * ECharts (sankey/fan/waterfall come from the same lib in later phases, + * keeping bundle simple). + */ +import { useMemo } from 'react'; +import ReactECharts from 'echarts-for-react'; +import type { EChartsOption } from 'echarts'; + +import type { api } from '@/api/client'; +import { gbpCompact } from '@/lib/format'; + +type History = Awaited>; + +interface Props { + history: History; + height?: number; +} + +export function NetWorthChart({ history, height = 360 }: Props) { + const option = useMemo(() => buildOption(history), [history]); + + if (history.points.length === 0) { + return

No history yet.

; + } + + return ; +} + +function buildOption(history: History): EChartsOption { + const accountNames = collectAccountNames(history); + + const seriesByAccount = accountNames.map((name) => ({ + name, + type: 'line' as const, + stack: 'nw', + areaStyle: { opacity: 0.6 }, + smooth: true, + showSymbol: false, + emphasis: { focus: 'series' as const }, + data: history.points.map((p) => Number(p.by_account[name] ?? 0)), + })); + + const dates = history.points.map((p) => p.snapshot_date); + + return { + grid: { left: 60, right: 24, top: 24, bottom: 40 }, + tooltip: { + trigger: 'axis', + valueFormatter: (v) => gbpCompact(typeof v === 'number' ? v : 0), + }, + legend: { + bottom: 0, + type: 'scroll', + }, + xAxis: { + type: 'category', + data: dates, + boundaryGap: false, + axisLabel: { hideOverlap: true }, + }, + yAxis: { + type: 'value', + axisLabel: { formatter: (v: number) => gbpCompact(v) }, + }, + series: seriesByAccount, + }; +} + +function collectAccountNames(history: History): string[] { + const names = new Set(); + for (const point of history.points) { + for (const name of Object.keys(point.by_account)) names.add(name); + } + return [...names].sort(); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 2a2d654..0b2706c 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,14 +1,23 @@ /** - * Dashboard — landing page. Wire up gets fleshed out in Phase 1a. - * For now: confirm we can reach /networth and render today's NW. + * Dashboard — net worth at a glance. + * + * Three sections, top to bottom: + * 1. Headline NW total (latest snapshot) + * 2. Stacked-area history chart (per-account, /networth/history) + * 3. Per-account cards (latest values, /networth) */ import { useQuery } from '@tanstack/react-query'; import { api } from '@/api/client'; +import { NetWorthChart } from '@/components/NetWorthChart'; import { gbp } from '@/lib/format'; export function Dashboard() { const nw = useQuery({ queryKey: ['networth', 'current'], queryFn: api.networth.current }); + const history = useQuery({ + queryKey: ['networth', 'history', 365], + queryFn: () => api.networth.history(365), + }); if (nw.isLoading) { return

Loading net worth…

; @@ -45,10 +54,23 @@ export function Dashboard() {

Net worth

As of {data.snapshot_date}

+
{gbp(data.total_gbp)}

{data.accounts.length} accounts

+ +
+

Last 12 months

+ {history.isLoading ? ( +

Loading…

+ ) : history.isError || !history.data ? ( +

History unavailable.

+ ) : ( + + )} +
+
{data.accounts.map((a) => (