Money tracker: vertical month/year gridlines on wealth chart, keep range in ?range= URL param
All checks were successful
Deploy production / deploy (push) Successful in 2m36s

This commit is contained in:
2026-10-24 02:25:12 +00:00
parent faeb76a7e7
commit 2af279e8a9
4 changed files with 140 additions and 6 deletions

View File

@@ -1,16 +1,19 @@
<script lang="ts"> <script lang="ts">
import type { ChartPoint, ChartSeries } from './charts'; import { timeGrid, yearLabels, type ChartPoint, type ChartSeries, type GridMode } from './charts';
let { let {
series, series,
title, title,
unit = '€', unit = '€',
marker marker,
grid
}: { }: {
series: ChartSeries[]; series: ChartSeries[];
title: string; title: string;
unit?: string; unit?: string;
marker?: { date: string; label: string }; marker?: { date: string; label: string };
/** Vertical gridlines: every month (1 January drawn stronger) or every year. */
grid?: GridMode;
} = $props(); } = $props();
let clientWidth = $state(800); let clientWidth = $state(800);
let width = $derived(Math.max(280, clientWidth)); let width = $derived(Math.max(280, clientWidth));
@@ -26,6 +29,14 @@
let from = $derived(Math.min(...times)); let from = $derived(Math.min(...times));
let until = $derived(Math.max(...times)); let until = $derived(Math.max(...times));
let dateSpan = $derived(until - from || 1); let dateSpan = $derived(until - from || 1);
let gridLines = $derived(
grid && values.length ? timeGrid(isoDate(from), isoDate(until), grid) : []
);
let axisTicks = $derived(
grid === 'years'
? yearLabels(gridLines, Math.max(2, Math.floor((width - plot.left - plot.right) / 44)))
: ticks()
);
let snapDates = $derived( let snapDates = $derived(
[...new Set(series.flatMap((item) => item.points.map((point) => point.date)))].sort() [...new Set(series.flatMap((item) => item.points.map((point) => point.date)))].sort()
); );
@@ -59,6 +70,9 @@
return { min, max, ticks }; return { min, max, ticks };
} }
function isoDate(ms: number) {
return new Date(ms).toISOString().slice(0, 10);
}
function time(date: string) { function time(date: string) {
return Date.parse(`${date}T12:00:00Z`); return Date.parse(`${date}T12:00:00Z`);
} }
@@ -108,6 +122,9 @@
} }
return result; return result;
} }
function tickLabel(date: string) {
return grid === 'years' ? date.slice(0, 4) : dateLabel(date);
}
function dateLabel(date: string, withDay = false) { function dateLabel(date: string, withDay = false) {
return new Date(time(date)).toLocaleDateString('en-NL', { return new Date(time(date)).toLocaleDateString('en-NL', {
day: withDay ? 'numeric' : undefined, day: withDay ? 'numeric' : undefined,
@@ -145,6 +162,16 @@
onpointerdown={hover} onpointerdown={hover}
onpointerleave={() => (hoverDate = null)} onpointerleave={() => (hoverDate = null)}
> >
{#each gridLines as line (line.date)}
<line
class="grid-x"
class:year={line.year && grid === 'months'}
x1={x(line.date)}
x2={x(line.date)}
y1={plot.top}
y2={height - plot.bottom}
/>
{/each}
{#each scale.ticks as value (value)} {#each scale.ticks as value (value)}
<line <line
x1={plot.left} x1={plot.left}
@@ -162,13 +189,13 @@
font-size="11">{format(value)}</text font-size="11">{format(value)}</text
> >
{/each} {/each}
{#each ticks() as tick (tick)} {#each axisTicks as tick (tick)}
<text <text
x={x(tick)} x={x(tick)}
y={height - 8} y={height - 8}
text-anchor="middle" text-anchor="middle"
fill="var(--site-muted)" fill="var(--site-muted)"
font-size="11">{dateLabel(tick)}</text font-size="11">{tickLabel(tick)}</text
> >
{/each} {/each}
{#if marker && time(marker.date) >= from && time(marker.date) <= until} {#if marker && time(marker.date) >= from && time(marker.date) <= until}
@@ -294,6 +321,18 @@
overflow: visible; overflow: visible;
user-select: none; user-select: none;
} }
/* Vertical time gridlines: quieter than the value gridlines so the data stays in front. */
.grid-x {
stroke: var(--site-border);
stroke-width: 1;
stroke-opacity: 0.55;
shape-rendering: crispEdges;
}
.grid-x.year {
stroke: var(--site-muted);
stroke-width: 1.5;
stroke-opacity: 0.4;
}
.tooltip { .tooltip {
position: absolute; position: absolute;
top: 4px; top: 4px;

View File

@@ -4,6 +4,9 @@ import {
accountColor, accountColor,
balanceSeries, balanceSeries,
forecastSeries, forecastSeries,
parseHistoryRange,
timeGrid,
yearLabels,
utilityEstimate, utilityEstimate,
wealthProjection, wealthProjection,
wealthTimeline wealthTimeline
@@ -308,4 +311,42 @@ describe('history range', () => {
expect(byAccount.map((line) => line.label)).toEqual(['Bank', 'Parents', 'Total']); expect(byAccount.map((line) => line.label)).toEqual(['Bank', 'Parents', 'Total']);
expect(byAccount.at(-1)!.points[0].value).toBe(1000); expect(byAccount.at(-1)!.points[0].value).toBe(1000);
}); });
it('reads the history range from the URL, defaulting to one year', () => {
expect(parseHistoryRange('3y')).toBe('3y');
expect(parseHistoryRange('all')).toBe('all');
expect(parseHistoryRange('1y')).toBe('1y');
expect(parseHistoryRange('5y')).toBe('1y');
expect(parseHistoryRange(null)).toBe('1y');
});
it('puts a gridline on every month start and marks 1 January as a year line', () => {
const lines = timeGrid('2025-10-24', '2026-03-01', 'months');
expect(lines).toEqual([
{ date: '2025-11-01', year: false },
{ date: '2025-12-01', year: false },
{ date: '2026-01-01', year: true },
{ date: '2026-02-01', year: false },
{ date: '2026-03-01', year: false }
]);
expect(timeGrid('2025-01-01', '2025-02-15', 'months').map((line) => line.date)).toEqual([
'2025-01-01',
'2025-02-01'
]);
});
it('puts only year lines on the all-time grid and thins crowded year labels', () => {
const lines = timeGrid('2007-02-01', '2027-10-24', 'years');
expect(lines).toHaveLength(20);
expect(lines.every((line) => line.year && line.date.endsWith('-01-01'))).toBe(true);
expect(lines[0].date).toBe('2008-01-01');
expect(yearLabels(lines, 30)).toHaveLength(20);
expect(yearLabels(lines, 6).map((date) => date.slice(0, 4))).toEqual([
'2010',
'2015',
'2020',
'2025'
]);
expect(timeGrid('2026-01-02', '2026-01-01', 'months')).toEqual([]);
});
}); });

View File

@@ -360,3 +360,45 @@ export function clipSeries(series: ChartSeries[], from: string | null): ChartSer
return { ...line, points }; return { ...line, points };
}); });
} }
/** Reads a `?range=` value, falling back to one year for anything unknown. */
export function parseHistoryRange(value: string | null | undefined): HistoryRange {
return value === '3y' || value === 'all' ? value : '1y';
}
export type GridMode = 'months' | 'years';
export type GridLine = { date: string; year: boolean };
/**
* Vertical gridlines between two dates (inclusive): the first of every month (`months`),
* or only 1 January (`years`). Lines on 1 January are flagged so they can be drawn stronger.
*/
export function timeGrid(from: string, until: string, mode: GridMode): GridLine[] {
const lines: GridLine[] = [];
if (!from || !until || from > until) return lines;
let year = Number(from.slice(0, 4));
let month = Number(from.slice(5, 7)) - 1;
if (from.slice(8) !== '01') month++;
for (let guard = 0; guard < 1200; guard++) {
if (month > 11) {
month = 0;
year++;
}
if (mode === 'years' && month !== 0) {
month = 12;
continue;
}
const date = `${year}-${String(month + 1).padStart(2, '0')}-01`;
if (date > until) break;
lines.push({ date, year: month === 0 });
month++;
}
return lines;
}
/** Year labels for the `years` grid, thinned to every 2nd/5th/10th year when crowded. */
export function yearLabels(lines: GridLine[], maxLabels: number): string[] {
const years = lines.filter((line) => line.year).map((line) => line.date);
const step = [1, 2, 5, 10, 20].find((size) => Math.ceil(years.length / size) <= maxLabels) ?? 50;
return years.filter((date) => Number(date.slice(0, 4)) % step === 0);
}

View File

@@ -19,6 +19,8 @@
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte'; import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
import { Badge } from '#lib/components/ui/badge/index.js'; import { Badge } from '#lib/components/ui/badge/index.js';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { Button } from '#lib/components/ui/button/index.js'; import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js'; import { Input } from '#lib/components/ui/input/index.js';
import * as Tabs from '#lib/components/ui/tabs/index.js'; import * as Tabs from '#lib/components/ui/tabs/index.js';
@@ -28,6 +30,7 @@
clipSeries, clipSeries,
type HistoryRange, type HistoryRange,
historyStart, historyStart,
parseHistoryRange,
monthlyCents, monthlyCents,
type TimelineView, type TimelineView,
utilityEstimate, utilityEstimate,
@@ -146,7 +149,15 @@
{ id: '3y', label: '3 years' }, { id: '3y', label: '3 years' },
{ id: 'all', label: 'All' } { id: 'all', label: 'All' }
]; ];
let historyRange = $state<HistoryRange>('1y'); // The range lives in ?range= so it survives reloads; 1y is the default.
let historyRange = $state<HistoryRange>(parseHistoryRange(page.url.searchParams.get('range')));
function setHistoryRange(value: string) {
historyRange = parseHistoryRange(value);
const url = new URL(window.location.href);
url.searchParams.set('range', historyRange);
// Shallow routing: rewrites the current history entry without a load, reload or scroll.
replaceState(url, page.state);
}
let timeline = $derived( let timeline = $derived(
clipSeries( clipSeries(
wealthTimeline(data.accounts, actualTransactions, projection, data.today, timelineView), wealthTimeline(data.accounts, actualTransactions, projection, data.today, timelineView),
@@ -327,7 +338,7 @@
>{/each}</Tabs.List >{/each}</Tabs.List
></Tabs.Root ></Tabs.Root
> >
<Tabs.Root bind:value={historyRange} aria-label="History range" <Tabs.Root value={historyRange} onValueChange={setHistoryRange} aria-label="History range"
><Tabs.List ><Tabs.List
>{#each historyRanges as item (item.id)}<Tabs.Trigger value={item.id} >{#each historyRanges as item (item.id)}<Tabs.Trigger value={item.id}
>{item.label}</Tabs.Trigger >{item.label}</Tabs.Trigger
@@ -339,6 +350,7 @@
series={timeline} series={timeline}
title="Actual and projected wealth over time" title="Actual and projected wealth over time"
marker={{ date: data.today, label: 'Today' }} marker={{ date: data.today, label: 'Today' }}
grid={historyRange === 'all' ? 'years' : 'months'}
/> />
<div class="projection-totals" aria-label="Projected balances at end of period"> <div class="projection-totals" aria-label="Projected balances at end of period">
<div> <div>