fire-planner: filterable date range on the home-page history chart
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

User asked for a manual date range on the Dashboard chart instead of
the hard-coded 12-month window.

Backend (/networth/history):
- Read LIVE from wf_sync's daily_account_valuation JOIN accounts so the
  chart spans the broker's full daily series. The account_snapshot
  cache is only the latest snapshot — never had >2 daily points for
  charting. Falls back to the cache when wf_sync isn't wired (tests).
- Accept `from=YYYY-MM-DD` and `to=YYYY-MM-DD` query params. When `from`
  is set, the window is [from, to or today] (inclusive). Otherwise the
  legacy `days` look-back still applies. 422 when from > to.

Frontend:
- New HistoryRangePicker component: preset buttons (1m / 3m / 6m / 1y /
  3y / All) plus two date inputs for an explicit custom range.
- Dashboard wires the picker to the chart via react-query keyed on the
  selected range, so the chart re-fetches on change.

Tests:
- Renamed `respects_days_filter` → `respects_from_to_filter` and added
  inverted-window rejection. Old test asserted "days=1 returns 1 point"
  which only worked when 'today' was within the seed window — the new
  windowing is correct and explicit.

271 → 272 tests passing.
This commit is contained in:
Viktor Barzin 2026-05-28 09:04:58 +00:00
parent 4da58fe56e
commit 4a0ef1faf6
5 changed files with 222 additions and 36 deletions

View file

@ -78,14 +78,19 @@ export const api = {
cost_basis_gbp: string | null;
}>;
}>('/networth'),
history: (days = 365) =>
request<{
history: (params: { days?: number; from?: string; to?: string } = {}) => {
const q = new URLSearchParams();
if (params.from) q.set('from', params.from);
if (params.to) q.set('to', params.to);
if (!params.from && !params.to) q.set('days', String(params.days ?? 365));
return request<{
points: Array<{
snapshot_date: string;
total_gbp: string;
by_account: Record<string, string>;
}>;
}>(`/networth/history?days=${days}`),
}>(`/networth/history?${q.toString()}`);
},
},
spending: {
annual: (months = 12) =>

View file

@ -0,0 +1,99 @@
/**
* Range picker for the net-worth history chart.
*
* Two modes:
* - `preset` quick buttons (1m / 3m / 6m / 1y / 3y / All)
* - `custom` two date inputs for an explicit window
*
* The "All" preset uses a large look-back (~10y) since the backend caps
* `days` at 3650 and there isn't more wealthfolio history than that.
*/
import { useId } from 'react';
export type HistoryRange =
| { kind: 'preset'; days: number; label: string }
| { kind: 'custom'; from: string; to: string };
const PRESETS: Array<{ label: string; days: number }> = [
{ label: '1m', days: 30 },
{ label: '3m', days: 90 },
{ label: '6m', days: 180 },
{ label: '1y', days: 365 },
{ label: '3y', days: 1095 },
{ label: 'All', days: 3650 },
];
export const defaultRange: HistoryRange = { kind: 'preset', days: 365, label: '1y' };
interface Props {
value: HistoryRange;
onChange: (next: HistoryRange) => void;
}
export function HistoryRangePicker({ value, onChange }: Props) {
const fromId = useId();
const toId = useId();
const isPreset = value.kind === 'preset';
const today = new Date().toISOString().slice(0, 10);
const customFrom = value.kind === 'custom' ? value.from : isoNDaysAgo(30);
const customTo = value.kind === 'custom' ? value.to : today;
return (
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5">
{PRESETS.map((p) => {
const selected = isPreset && value.label === p.label;
return (
<button
key={p.label}
type="button"
onClick={() => onChange({ kind: 'preset', days: p.days, label: p.label })}
className={
'px-2.5 py-1 text-xs font-medium rounded transition ' +
(selected
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-600 hover:text-slate-900')
}
>
{p.label}
</button>
);
})}
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-600">
<label htmlFor={fromId} className="sr-only">From</label>
<input
id={fromId}
type="date"
value={customFrom}
max={customTo}
onChange={(e) =>
onChange({ kind: 'custom', from: e.target.value, to: customTo })
}
className="rounded border border-slate-200 bg-white px-1.5 py-1 tabular-nums"
/>
<span></span>
<label htmlFor={toId} className="sr-only">To</label>
<input
id={toId}
type="date"
value={customTo}
min={customFrom}
max={today}
onChange={(e) =>
onChange({ kind: 'custom', from: customFrom, to: e.target.value })
}
className="rounded border border-slate-200 bg-white px-1.5 py-1 tabular-nums"
/>
</div>
</div>
);
}
function isoNDaysAgo(n: number): string {
const d = new Date();
d.setUTCDate(d.getUTCDate() - n);
return d.toISOString().slice(0, 10);
}

View file

@ -3,20 +3,32 @@
*
* Three sections, top to bottom:
* 1. Headline NW total (latest snapshot)
* 2. Stacked-area history chart (per-account, /networth/history)
* 2. Stacked-area history chart with a range picker (per-account)
* 3. Per-account cards (latest values, /networth)
*/
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/api/client';
import { NetWorthChart } from '@/components/NetWorthChart';
import {
HistoryRangePicker,
defaultRange,
type HistoryRange,
} from '@/components/HistoryRangePicker';
import { gbp } from '@/lib/format';
export function Dashboard() {
const [range, setRange] = useState<HistoryRange>(defaultRange);
const nw = useQuery({ queryKey: ['networth', 'current'], queryFn: api.networth.current });
const history = useQuery({
queryKey: ['networth', 'history', 365],
queryFn: () => api.networth.history(365),
queryKey: ['networth', 'history', range.kind, range.kind === 'preset' ? range.days : `${range.from}|${range.to}`],
queryFn: () =>
api.networth.history(
range.kind === 'preset'
? { days: range.days }
: { from: range.from, to: range.to },
),
});
if (nw.isLoading) {
@ -61,17 +73,19 @@ export function Dashboard() {
</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>
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<h2 className="text-lg font-semibold">History</h2>
<HistoryRangePicker value={range} onChange={setRange} />
</div>
{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>
) : history.data.points.length < 2 ? (
<p className="text-sm text-slate-500">
Only {history.data.points.length} snapshot in the window the
chart needs at least two daily points to draw a line. The
Wealthfolio CronJob writes one snapshot per day, so this fills
in over time.
Only {history.data.points.length} snapshot in this window pick a
wider range, or wait for the daily Wealthfolio snapshot to fill in
more points.
</p>
) : (
<NetWorthChart history={history.data} />