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.
|
||||
* 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue