fire-planner: filterable date range on the home-page history chart
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
4da58fe56e
commit
4a0ef1faf6
5 changed files with 222 additions and 36 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
99
frontend/src/components/HistoryRangePicker.tsx
Normal file
99
frontend/src/components/HistoryRangePicker.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue