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">
|
<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;
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user