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
All checks were successful
Deploy production / deploy (push) Successful in 2m36s
This commit is contained in:
@@ -1,16 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { ChartPoint, ChartSeries } from './charts';
|
||||
import { timeGrid, yearLabels, type ChartPoint, type ChartSeries, type GridMode } from './charts';
|
||||
|
||||
let {
|
||||
series,
|
||||
title,
|
||||
unit = '€',
|
||||
marker
|
||||
marker,
|
||||
grid
|
||||
}: {
|
||||
series: ChartSeries[];
|
||||
title: string;
|
||||
unit?: string;
|
||||
marker?: { date: string; label: string };
|
||||
/** Vertical gridlines: every month (1 January drawn stronger) or every year. */
|
||||
grid?: GridMode;
|
||||
} = $props();
|
||||
let clientWidth = $state(800);
|
||||
let width = $derived(Math.max(280, clientWidth));
|
||||
@@ -26,6 +29,14 @@
|
||||
let from = $derived(Math.min(...times));
|
||||
let until = $derived(Math.max(...times));
|
||||
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(
|
||||
[...new Set(series.flatMap((item) => item.points.map((point) => point.date)))].sort()
|
||||
);
|
||||
@@ -59,6 +70,9 @@
|
||||
return { min, max, ticks };
|
||||
}
|
||||
|
||||
function isoDate(ms: number) {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
}
|
||||
function time(date: string) {
|
||||
return Date.parse(`${date}T12:00:00Z`);
|
||||
}
|
||||
@@ -108,6 +122,9 @@
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function tickLabel(date: string) {
|
||||
return grid === 'years' ? date.slice(0, 4) : dateLabel(date);
|
||||
}
|
||||
function dateLabel(date: string, withDay = false) {
|
||||
return new Date(time(date)).toLocaleDateString('en-NL', {
|
||||
day: withDay ? 'numeric' : undefined,
|
||||
@@ -145,6 +162,16 @@
|
||||
onpointerdown={hover}
|
||||
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)}
|
||||
<line
|
||||
x1={plot.left}
|
||||
@@ -162,13 +189,13 @@
|
||||
font-size="11">{format(value)}</text
|
||||
>
|
||||
{/each}
|
||||
{#each ticks() as tick (tick)}
|
||||
{#each axisTicks as tick (tick)}
|
||||
<text
|
||||
x={x(tick)}
|
||||
y={height - 8}
|
||||
text-anchor="middle"
|
||||
fill="var(--site-muted)"
|
||||
font-size="11">{dateLabel(tick)}</text
|
||||
font-size="11">{tickLabel(tick)}</text
|
||||
>
|
||||
{/each}
|
||||
{#if marker && time(marker.date) >= from && time(marker.date) <= until}
|
||||
@@ -294,6 +321,18 @@
|
||||
overflow: visible;
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
accountColor,
|
||||
balanceSeries,
|
||||
forecastSeries,
|
||||
parseHistoryRange,
|
||||
timeGrid,
|
||||
yearLabels,
|
||||
utilityEstimate,
|
||||
wealthProjection,
|
||||
wealthTimeline
|
||||
@@ -308,4 +311,42 @@ describe('history range', () => {
|
||||
expect(byAccount.map((line) => line.label)).toEqual(['Bank', 'Parents', 'Total']);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -360,3 +360,45 @@ export function clipSeries(series: ChartSeries[], from: string | null): ChartSer
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
|
||||
import { Badge } from '#lib/components/ui/badge/index.js';
|
||||
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 { Input } from '#lib/components/ui/input/index.js';
|
||||
import * as Tabs from '#lib/components/ui/tabs/index.js';
|
||||
@@ -28,6 +30,7 @@
|
||||
clipSeries,
|
||||
type HistoryRange,
|
||||
historyStart,
|
||||
parseHistoryRange,
|
||||
monthlyCents,
|
||||
type TimelineView,
|
||||
utilityEstimate,
|
||||
@@ -146,7 +149,15 @@
|
||||
{ id: '3y', label: '3 years' },
|
||||
{ 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(
|
||||
clipSeries(
|
||||
wealthTimeline(data.accounts, actualTransactions, projection, data.today, timelineView),
|
||||
@@ -327,7 +338,7 @@
|
||||
>{/each}</Tabs.List
|
||||
></Tabs.Root
|
||||
>
|
||||
<Tabs.Root bind:value={historyRange} aria-label="History range"
|
||||
<Tabs.Root value={historyRange} onValueChange={setHistoryRange} aria-label="History range"
|
||||
><Tabs.List
|
||||
>{#each historyRanges as item (item.id)}<Tabs.Trigger value={item.id}
|
||||
>{item.label}</Tabs.Trigger
|
||||
@@ -339,6 +350,7 @@
|
||||
series={timeline}
|
||||
title="Actual and projected wealth over time"
|
||||
marker={{ date: data.today, label: 'Today' }}
|
||||
grid={historyRange === 'all' ? 'years' : 'months'}
|
||||
/>
|
||||
<div class="projection-totals" aria-label="Projected balances at end of period">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user