frontend: stacked-area NW history chart on the dashboard
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

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 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-05-09 21:55:30 +00:00
parent f4539f9e6d
commit 5d2b9e931a
3 changed files with 141 additions and 2 deletions

View file

@ -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 }) => (
<div data-testid="chart">{JSON.stringify(option).length}</div>
),
}));
describe('NetWorthChart', () => {
it('shows an empty-state when there are no points', () => {
render(<NetWorthChart history={{ points: [] }} />);
expect(screen.getByText('No history yet.')).toBeInTheDocument();
});
it('renders an ECharts option per account', () => {
render(
<NetWorthChart
history={{
points: [
{
snapshot_date: '2026-04-23',
total_gbp: '895000',
by_account: { ISA: '280000', Schwab: '615000' },
},
{
snapshot_date: '2026-04-25',
total_gbp: '940000',
by_account: { ISA: '300000', Schwab: '640000' },
},
],
}}
/>,
);
expect(screen.getByTestId('chart')).toBeInTheDocument();
});
});

View file

@ -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<ReturnType<typeof api.networth.history>>;
interface Props {
history: History;
height?: number;
}
export function NetWorthChart({ history, height = 360 }: Props) {
const option = useMemo<EChartsOption>(() => buildOption(history), [history]);
if (history.points.length === 0) {
return <p className="text-sm text-slate-500">No history yet.</p>;
}
return <ReactECharts option={option} style={{ height, width: '100%' }} notMerge lazyUpdate />;
}
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<string>();
for (const point of history.points) {
for (const name of Object.keys(point.by_account)) names.add(name);
}
return [...names].sort();
}

View file

@ -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 <p className="text-slate-500">Loading net worth</p>;
@ -45,10 +54,23 @@ export function Dashboard() {
<h1 className="text-3xl font-semibold tracking-tight">Net worth</h1>
<p className="text-sm text-slate-500">As of {data.snapshot_date}</p>
</header>
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="text-4xl font-semibold tabular-nums">{gbp(data.total_gbp)}</div>
<p className="text-sm text-slate-500 mt-1">{data.accounts.length} accounts</p>
</div>
<div className="rounded-lg border border-slate-200 bg-white p-6">
<h2 className="text-lg font-semibold mb-4">Last 12 months</h2>
{history.isLoading ? (
<p className="text-sm text-slate-500">Loading</p>
) : history.isError || !history.data ? (
<p className="text-sm text-slate-500">History unavailable.</p>
) : (
<NetWorthChart history={history.data} />
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data.accounts.map((a) => (
<div