frontend: stacked-area NW history chart on the dashboard
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
f4539f9e6d
commit
5d2b9e931a
3 changed files with 141 additions and 2 deletions
39
frontend/src/components/NetWorthChart.test.tsx
Normal file
39
frontend/src/components/NetWorthChart.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
78
frontend/src/components/NetWorthChart.tsx
Normal file
78
frontend/src/components/NetWorthChart.tsx
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
/**
|
/**
|
||||||
* Dashboard — landing page. Wire up gets fleshed out in Phase 1a.
|
* Dashboard — net worth at a glance.
|
||||||
* For now: confirm we can reach /networth and render today's NW.
|
*
|
||||||
|
* 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 { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
|
import { NetWorthChart } from '@/components/NetWorthChart';
|
||||||
import { gbp } from '@/lib/format';
|
import { gbp } from '@/lib/format';
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const nw = useQuery({ queryKey: ['networth', 'current'], queryFn: api.networth.current });
|
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) {
|
if (nw.isLoading) {
|
||||||
return <p className="text-slate-500">Loading net worth…</p>;
|
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>
|
<h1 className="text-3xl font-semibold tracking-tight">Net worth</h1>
|
||||||
<p className="text-sm text-slate-500">As of {data.snapshot_date}</p>
|
<p className="text-sm text-slate-500">As of {data.snapshot_date}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-6">
|
<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>
|
<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>
|
<p className="text-sm text-slate-500 mt-1">{data.accounts.length} accounts</p>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{data.accounts.map((a) => (
|
{data.accounts.map((a) => (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue