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
|
|
@ -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} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue