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

@ -1,19 +1,25 @@
"""Net-worth read endpoints.
Reads from `fire_planner.account_snapshot`, which is lazily refreshed
from the wealthfolio_sync PG mirror on each request when older than
`NETWORTH_CACHE_TTL_DAYS` (default 1). Two views:
- GET /networth latest snapshot per account, totals
- GET /networth/history daily totals + per-account series, for charts
- GET /networth latest snapshot per account, totals.
Backed by the `fire_planner.account_snapshot` cache, lazily refreshed
from the wealthfolio_sync PG mirror on each request when older than
`NETWORTH_CACHE_TTL_DAYS` (default 1).
- GET /networth/history daily NW totals + per-account series.
Reads LIVE from wealthfolio_sync's `daily_account_valuation` table so
the chart always reflects the full daily series (the cache only holds
the latest snapshot). Falls back to `account_snapshot` when wf_sync is
unavailable (tests, dev).
"""
from __future__ import annotations
from collections import defaultdict
from datetime import date
from collections.abc import Sequence
from datetime import date, timedelta
from decimal import Decimal
from typing import Any
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from fire_planner.api.dependencies import get_session, get_wf_sync_session
@ -53,34 +59,89 @@ async def current_networth(
async def networth_history(
session: AsyncSession = Depends(get_session),
wf_sync: AsyncSession | None = Depends(get_wf_sync_session),
days: int = Query(default=365, ge=1, le=3650, description="Look-back window."),
days: int = Query(default=365, ge=1, le=3650, description="Look-back window in days."),
from_date: date | None = Query(default=None, alias="from",
description="Inclusive start date (YYYY-MM-DD). "
"Overrides `days` when set together with `to`."),
to_date: date | None = Query(default=None, alias="to",
description="Inclusive end date (YYYY-MM-DD). "
"Defaults to today when only `from` is set."),
) -> NetWorthHistory:
"""Daily NW total + per-account breakdown for a stacked area chart.
Picks one row per (account_id, snapshot_date) wealthfolio ingest
upserts daily so this is already de-duped, but we group defensively.
Window selection:
- If `from` is set, use [from, to or today].
- Otherwise use the last `days` days ending today.
Reads live from wf_sync's `daily_account_valuation` JOIN `accounts`
so the chart spans the broker's full daily series. Falls back to the
`account_snapshot` cache only when wf_sync isn't wired (tests, dev).
"""
await refresh_account_snapshots_if_stale(session, wf_sync)
today = date.today()
if from_date is not None:
if to_date is None:
to_date = today
if from_date > to_date:
raise HTTPException(status_code=422, detail="`from` must be on or before `to`")
window_start, window_end = from_date, to_date
else:
window_end = to_date or today
window_start = window_end - timedelta(days=max(days - 1, 0))
points = await _history_from_wf_sync(wf_sync, window_start, window_end)
if points is None:
# wf_sync not wired — fall back to the cache. Useful for tests
# and for environments without the mirror.
points = await _history_from_cache(session, window_start, window_end)
return NetWorthHistory(points=points)
async def _history_from_wf_sync(
wf_sync: AsyncSession | None,
window_start: date,
window_end: date,
) -> list[NetWorthHistoryPoint] | None:
if wf_sync is None:
return None
rows = (await wf_sync.execute(
text("""
SELECT d.valuation_date,
a.name,
d.total_value * COALESCE(d.fx_rate_to_base, 1.0) AS market_value_gbp
FROM daily_account_valuation d
JOIN accounts a ON a.id = d.account_id
WHERE d.valuation_date BETWEEN :start AND :end
ORDER BY d.valuation_date
"""),
{"start": window_start, "end": window_end})).all()
if not rows:
return []
return _group_by_date(rows)
async def _history_from_cache(
session: AsyncSession,
window_start: date,
window_end: date,
) -> list[NetWorthHistoryPoint]:
rows = (await session.execute(
select(
AccountSnapshot.snapshot_date,
AccountSnapshot.account_name,
AccountSnapshot.market_value_gbp,
).order_by(AccountSnapshot.snapshot_date))).all()
if not rows:
return NetWorthHistory(points=[])
).where(AccountSnapshot.snapshot_date.between(window_start, window_end))
.order_by(AccountSnapshot.snapshot_date))).all()
return _group_by_date(rows)
def _group_by_date(rows: Sequence[Any]) -> list[NetWorthHistoryPoint]:
by_date: dict[date, dict[str, Decimal]] = defaultdict(lambda: defaultdict(lambda: Decimal("0")))
for snap_date, name, value in rows:
by_date[snap_date][name] += Decimal(str(value))
cutoff_idx = max(0, len(by_date) - days)
sorted_dates = sorted(by_date.keys())[cutoff_idx:]
points = [
return [
NetWorthHistoryPoint(
snapshot_date=d,
total_gbp=sum(by_date[d].values(), Decimal("0")),
by_account=dict(by_date[d]),
) for d in sorted_dates
) for d in sorted(by_date.keys())
]
return NetWorthHistory(points=points)

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} />

View file

@ -111,12 +111,19 @@ async def test_networth_history_returns_per_date(client: AsyncClient,
assert Decimal(by_date["2026-04-25"]["by_account"]["ISA"]) == Decimal("300000")
async def test_networth_history_respects_days_filter(
async def test_networth_history_respects_from_to_filter(
client: AsyncClient,
session: AsyncSession,
) -> None:
"""The `from`/`to` params return only points in [from, to] inclusive."""
await _seed_snapshots(session)
resp = await client.get("/networth/history?days=1")
resp = await client.get("/networth/history?from=2026-04-25&to=2026-04-25")
assert resp.status_code == 200
# days=1 ⇒ only the latest 1 distinct date
assert len(resp.json()["points"]) == 1
points = resp.json()["points"]
assert len(points) == 1
assert points[0]["snapshot_date"] == "2026-04-25"
async def test_networth_history_rejects_inverted_window(client: AsyncClient) -> None:
resp = await client.get("/networth/history?from=2026-05-01&to=2026-04-01")
assert resp.status_code == 422