diff --git a/fire_planner/api/networth.py b/fire_planner/api/networth.py index 9323b07..6b06ee1 100644 --- a/fire_planner/api/networth.py +++ b/fire_planner/api/networth.py @@ -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) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 035fbb6..1219e5a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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; }>; - }>(`/networth/history?days=${days}`), + }>(`/networth/history?${q.toString()}`); + }, }, spending: { annual: (months = 12) => diff --git a/frontend/src/components/HistoryRangePicker.tsx b/frontend/src/components/HistoryRangePicker.tsx new file mode 100644 index 0000000..06deec4 --- /dev/null +++ b/frontend/src/components/HistoryRangePicker.tsx @@ -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 ( +
+
+ {PRESETS.map((p) => { + const selected = isPreset && value.label === p.label; + return ( + + ); + })} +
+ +
+ + + onChange({ kind: 'custom', from: e.target.value, to: customTo }) + } + className="rounded border border-slate-200 bg-white px-1.5 py-1 tabular-nums" + /> + + + + onChange({ kind: 'custom', from: customFrom, to: e.target.value }) + } + className="rounded border border-slate-200 bg-white px-1.5 py-1 tabular-nums" + /> +
+
+ ); +} + +function isoNDaysAgo(n: number): string { + const d = new Date(); + d.setUTCDate(d.getUTCDate() - n); + return d.toISOString().slice(0, 10); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 9cebc12..26d39a9 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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(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() {
-

Last 12 months

+
+

History

+ +
{history.isLoading ? (

Loading…

) : history.isError || !history.data ? (

History unavailable.

) : history.data.points.length < 2 ? (

- 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.

) : ( diff --git a/tests/test_api_networth.py b/tests/test_api_networth.py index 564ab8d..40be840 100644 --- a/tests/test_api_networth.py +++ b/tests/test_api_networth.py @@ -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