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.
|
"""Net-worth read endpoints.
|
||||||
|
|
||||||
Reads from `fire_planner.account_snapshot`, which is lazily refreshed
|
- GET /networth → latest snapshot per account, totals.
|
||||||
from the wealthfolio_sync PG mirror on each request when older than
|
Backed by the `fire_planner.account_snapshot` cache, lazily refreshed
|
||||||
`NETWORTH_CACHE_TTL_DAYS` (default 1). Two views:
|
from the wealthfolio_sync PG mirror on each request when older than
|
||||||
- GET /networth → latest snapshot per account, totals
|
`NETWORTH_CACHE_TTL_DAYS` (default 1).
|
||||||
- GET /networth/history → daily totals + per-account series, for charts
|
- 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 __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import date
|
from collections.abc import Sequence
|
||||||
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from fire_planner.api.dependencies import get_session, get_wf_sync_session
|
from fire_planner.api.dependencies import get_session, get_wf_sync_session
|
||||||
|
|
@ -53,34 +59,89 @@ async def current_networth(
|
||||||
async def networth_history(
|
async def networth_history(
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
wf_sync: AsyncSession | None = Depends(get_wf_sync_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:
|
) -> NetWorthHistory:
|
||||||
"""Daily NW total + per-account breakdown for a stacked area chart.
|
"""Daily NW total + per-account breakdown for a stacked area chart.
|
||||||
|
|
||||||
Picks one row per (account_id, snapshot_date) — wealthfolio ingest
|
Window selection:
|
||||||
upserts daily so this is already de-duped, but we group defensively.
|
- 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(
|
rows = (await session.execute(
|
||||||
select(
|
select(
|
||||||
AccountSnapshot.snapshot_date,
|
AccountSnapshot.snapshot_date,
|
||||||
AccountSnapshot.account_name,
|
AccountSnapshot.account_name,
|
||||||
AccountSnapshot.market_value_gbp,
|
AccountSnapshot.market_value_gbp,
|
||||||
).order_by(AccountSnapshot.snapshot_date))).all()
|
).where(AccountSnapshot.snapshot_date.between(window_start, window_end))
|
||||||
if not rows:
|
.order_by(AccountSnapshot.snapshot_date))).all()
|
||||||
return NetWorthHistory(points=[])
|
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")))
|
by_date: dict[date, dict[str, Decimal]] = defaultdict(lambda: defaultdict(lambda: Decimal("0")))
|
||||||
for snap_date, name, value in rows:
|
for snap_date, name, value in rows:
|
||||||
by_date[snap_date][name] += Decimal(str(value))
|
by_date[snap_date][name] += Decimal(str(value))
|
||||||
|
return [
|
||||||
cutoff_idx = max(0, len(by_date) - days)
|
|
||||||
sorted_dates = sorted(by_date.keys())[cutoff_idx:]
|
|
||||||
points = [
|
|
||||||
NetWorthHistoryPoint(
|
NetWorthHistoryPoint(
|
||||||
snapshot_date=d,
|
snapshot_date=d,
|
||||||
total_gbp=sum(by_date[d].values(), Decimal("0")),
|
total_gbp=sum(by_date[d].values(), Decimal("0")),
|
||||||
by_account=dict(by_date[d]),
|
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;
|
cost_basis_gbp: string | null;
|
||||||
}>;
|
}>;
|
||||||
}>('/networth'),
|
}>('/networth'),
|
||||||
history: (days = 365) =>
|
history: (params: { days?: number; from?: string; to?: string } = {}) => {
|
||||||
request<{
|
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<{
|
points: Array<{
|
||||||
snapshot_date: string;
|
snapshot_date: string;
|
||||||
total_gbp: string;
|
total_gbp: string;
|
||||||
by_account: Record<string, string>;
|
by_account: Record<string, string>;
|
||||||
}>;
|
}>;
|
||||||
}>(`/networth/history?days=${days}`),
|
}>(`/networth/history?${q.toString()}`);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
spending: {
|
spending: {
|
||||||
annual: (months = 12) =>
|
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:
|
* Three sections, top to bottom:
|
||||||
* 1. Headline NW total (latest snapshot)
|
* 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)
|
* 3. Per-account cards (latest values, /networth)
|
||||||
*/
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
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 { NetWorthChart } from '@/components/NetWorthChart';
|
||||||
|
import {
|
||||||
|
HistoryRangePicker,
|
||||||
|
defaultRange,
|
||||||
|
type HistoryRange,
|
||||||
|
} from '@/components/HistoryRangePicker';
|
||||||
import { gbp } from '@/lib/format';
|
import { gbp } from '@/lib/format';
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
|
const [range, setRange] = useState<HistoryRange>(defaultRange);
|
||||||
const nw = useQuery({ queryKey: ['networth', 'current'], queryFn: api.networth.current });
|
const nw = useQuery({ queryKey: ['networth', 'current'], queryFn: api.networth.current });
|
||||||
const history = useQuery({
|
const history = useQuery({
|
||||||
queryKey: ['networth', 'history', 365],
|
queryKey: ['networth', 'history', range.kind, range.kind === 'preset' ? range.days : `${range.from}|${range.to}`],
|
||||||
queryFn: () => api.networth.history(365),
|
queryFn: () =>
|
||||||
|
api.networth.history(
|
||||||
|
range.kind === 'preset'
|
||||||
|
? { days: range.days }
|
||||||
|
: { from: range.from, to: range.to },
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (nw.isLoading) {
|
if (nw.isLoading) {
|
||||||
|
|
@ -61,17 +73,19 @@ export function Dashboard() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-6">
|
<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 ? (
|
{history.isLoading ? (
|
||||||
<p className="text-sm text-slate-500">Loading…</p>
|
<p className="text-sm text-slate-500">Loading…</p>
|
||||||
) : history.isError || !history.data ? (
|
) : history.isError || !history.data ? (
|
||||||
<p className="text-sm text-slate-500">History unavailable.</p>
|
<p className="text-sm text-slate-500">History unavailable.</p>
|
||||||
) : history.data.points.length < 2 ? (
|
) : history.data.points.length < 2 ? (
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
Only {history.data.points.length} snapshot in the window — the
|
Only {history.data.points.length} snapshot in this window — pick a
|
||||||
chart needs at least two daily points to draw a line. The
|
wider range, or wait for the daily Wealthfolio snapshot to fill in
|
||||||
Wealthfolio CronJob writes one snapshot per day, so this fills
|
more points.
|
||||||
in over time.
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<NetWorthChart history={history.data} />
|
<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")
|
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,
|
client: AsyncClient,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""The `from`/`to` params return only points in [from, to] inclusive."""
|
||||||
await _seed_snapshots(session)
|
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
|
assert resp.status_code == 200
|
||||||
# days=1 ⇒ only the latest 1 distinct date
|
points = resp.json()["points"]
|
||||||
assert len(resp.json()["points"]) == 1
|
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