add icons to lists and solve money tracker bugs
All checks were successful
Test and deploy staging / staging (push) Successful in 2m30s
All checks were successful
Test and deploy staging / staging (push) Successful in 2m30s
This commit is contained in:
@@ -1,35 +1,74 @@
|
||||
<script lang="ts">
|
||||
import type { ChartSeries } from './charts';
|
||||
import type { ChartPoint, ChartSeries } from './charts';
|
||||
|
||||
let {
|
||||
series,
|
||||
title,
|
||||
unit = '€'
|
||||
}: { series: ChartSeries[]; title: string; unit?: string } = $props();
|
||||
const width = 800;
|
||||
const height = 260;
|
||||
const plot = { left: 72, right: 18, top: 14, bottom: 38 };
|
||||
unit = '€',
|
||||
marker
|
||||
}: {
|
||||
series: ChartSeries[];
|
||||
title: string;
|
||||
unit?: string;
|
||||
marker?: { date: string; label: string };
|
||||
} = $props();
|
||||
let clientWidth = $state(800);
|
||||
let width = $derived(Math.max(280, clientWidth));
|
||||
let compact = $derived(width < 520);
|
||||
let height = $derived(compact ? 240 : 280);
|
||||
let plot = $derived({ left: compact ? 52 : 72, right: 14, top: 18, bottom: 30 });
|
||||
let values = $derived(series.flatMap((item) => item.points.map((point) => point.value)));
|
||||
let dates = $derived(
|
||||
series.flatMap((item) => item.points.map((point) => Date.parse(`${point.date}T12:00:00Z`)))
|
||||
);
|
||||
let min = $derived(Math.min(0, ...values));
|
||||
let max = $derived(Math.max(0, ...values));
|
||||
let times = $derived(series.flatMap((item) => item.points.map((point) => time(point.date))));
|
||||
let scale = $derived(niceScale(Math.min(0, ...values), Math.max(0, ...values)));
|
||||
let min = $derived(scale.min);
|
||||
let max = $derived(scale.max);
|
||||
let span = $derived(max - min || 1);
|
||||
let from = $derived(Math.min(...dates));
|
||||
let until = $derived(Math.max(...dates));
|
||||
let from = $derived(Math.min(...times));
|
||||
let until = $derived(Math.max(...times));
|
||||
let dateSpan = $derived(until - from || 1);
|
||||
let snapDates = $derived(
|
||||
[...new Set(series.flatMap((item) => item.points.map((point) => point.date)))].sort()
|
||||
);
|
||||
let hoverDate = $state<string | null>(null);
|
||||
let hoverRows = $derived(
|
||||
hoverDate === null
|
||||
? []
|
||||
: series.flatMap((item) => {
|
||||
const point = pointAt(item.points, hoverDate!);
|
||||
return point ? [{ item, point }] : [];
|
||||
})
|
||||
);
|
||||
const money = new Intl.NumberFormat('en-NL', { maximumFractionDigits: 0 });
|
||||
const euros = new Intl.NumberFormat('en-NL', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
const exact = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
|
||||
|
||||
/** Rounds the value range out to 1/2/5 × 10ⁿ steps so gridlines land on round amounts. */
|
||||
function niceScale(low: number, high: number) {
|
||||
const raw = (high - low || 1) / 3;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(raw));
|
||||
const step = ([1, 2, 2.5, 5, 10].find((factor) => factor * magnitude >= raw) ?? 10) * magnitude;
|
||||
// A dip of a few euros below zero should not add a whole empty gridline band.
|
||||
const min = low > -step * 0.05 ? 0 : Math.floor(low / step) * step;
|
||||
const max = Math.ceil(high / step) * step;
|
||||
const ticks: number[] = [];
|
||||
for (let value = max; value >= min - step / 2; value -= step) ticks.push(value);
|
||||
return { min, max, ticks };
|
||||
}
|
||||
|
||||
function time(date: string) {
|
||||
return Date.parse(`${date}T12:00:00Z`);
|
||||
}
|
||||
function x(date: string) {
|
||||
return (
|
||||
plot.left +
|
||||
((Date.parse(`${date}T12:00:00Z`) - from) / dateSpan) * (width - plot.left - plot.right)
|
||||
);
|
||||
return plot.left + ((time(date) - from) / dateSpan) * (width - plot.left - plot.right);
|
||||
}
|
||||
function y(value: number) {
|
||||
return plot.top + (1 - (value - min) / span) * (height - plot.top - plot.bottom);
|
||||
}
|
||||
function path(points: ChartSeries['points']) {
|
||||
function path(points: ChartPoint[]) {
|
||||
return points
|
||||
.map(
|
||||
(point, index) =>
|
||||
@@ -37,26 +76,76 @@
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
function dateLabel(value: number) {
|
||||
return new Date(value).toLocaleDateString('en-NL', {
|
||||
/** Solid part through `dashedFrom` and dashed part from the last solid point onward. */
|
||||
function segments(item: ChartSeries) {
|
||||
if (!item.dashedFrom) return { solid: item.points, dashed: [] };
|
||||
const split = item.points.findLastIndex((point) => point.date <= item.dashedFrom!);
|
||||
return split < 0
|
||||
? { solid: [], dashed: item.points }
|
||||
: { solid: item.points.slice(0, split + 1), dashed: item.points.slice(split) };
|
||||
}
|
||||
function pointAt(points: ChartPoint[], date: string) {
|
||||
if (!points.length || date < points[0].date || date > points.at(-1)!.date) return null;
|
||||
return points.findLast((point) => point.date <= date) ?? null;
|
||||
}
|
||||
function ticks() {
|
||||
const months = (until - from) / (30.44 * 86_400_000);
|
||||
const step = [1, 2, 3, 6, 12, 24].find((size) => months / size <= (compact ? 3 : 6)) ?? 48;
|
||||
const start = new Date(from);
|
||||
const result: string[] = [];
|
||||
let year = start.getUTCFullYear();
|
||||
let month = start.getUTCMonth() + 1;
|
||||
month = Math.ceil(month / step) * step;
|
||||
for (let guard = 0; guard < 60; guard++) {
|
||||
while (month >= 12) {
|
||||
month -= 12;
|
||||
year++;
|
||||
}
|
||||
const date = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
||||
if (time(date) > until) break;
|
||||
if (time(date) >= from) result.push(date);
|
||||
month += step;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function dateLabel(date: string, withDay = false) {
|
||||
return new Date(time(date)).toLocaleDateString('en-NL', {
|
||||
day: withDay ? 'numeric' : undefined,
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
year: withDay ? 'numeric' : '2-digit',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
}
|
||||
function format(value: number, precise = false) {
|
||||
if (unit !== '€') return `${money.format(value)} ${unit}`;
|
||||
return (precise ? exact : euros).format(value / 100);
|
||||
}
|
||||
function hover(event: PointerEvent) {
|
||||
const svg = event.currentTarget as SVGSVGElement;
|
||||
const box = svg.getBoundingClientRect();
|
||||
const position = ((event.clientX - box.left) / box.width) * width;
|
||||
const target = from + ((position - plot.left) / (width - plot.left - plot.right)) * dateSpan;
|
||||
let best: string | null = null;
|
||||
for (const date of snapDates)
|
||||
if (best === null || Math.abs(time(date) - target) < Math.abs(time(best) - target))
|
||||
best = date;
|
||||
hoverDate = best;
|
||||
}
|
||||
</script>
|
||||
|
||||
<figure class="chart">
|
||||
{#if values.length}
|
||||
<div class="chart-scroll" role="region" aria-label={title}>
|
||||
<div class="chart-area" bind:clientWidth>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
width={clientWidth ? undefined : width}
|
||||
role="img"
|
||||
aria-label={title}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
onpointermove={hover}
|
||||
onpointerdown={hover}
|
||||
onpointerleave={() => (hoverDate = null)}
|
||||
>
|
||||
{#each [0, 0.5, 1] as fraction (fraction)}
|
||||
{@const value = max - span * fraction}
|
||||
{#each scale.ticks as value (value)}
|
||||
<line
|
||||
x1={plot.left}
|
||||
x2={width - plot.right}
|
||||
@@ -66,53 +155,123 @@
|
||||
stroke-width="1"
|
||||
/>
|
||||
<text
|
||||
x={plot.left - 12}
|
||||
x={plot.left - 8}
|
||||
y={y(value) + 4}
|
||||
text-anchor="end"
|
||||
fill="var(--site-muted)"
|
||||
font-size="11">{unit}{money.format(value / (unit === '€' ? 100 : 1))}</text
|
||||
font-size="11">{format(value)}</text
|
||||
>
|
||||
{/each}
|
||||
{#each ticks() as tick (tick)}
|
||||
<text
|
||||
x={x(tick)}
|
||||
y={height - 8}
|
||||
text-anchor="middle"
|
||||
fill="var(--site-muted)"
|
||||
font-size="11">{dateLabel(tick)}</text
|
||||
>
|
||||
{/each}
|
||||
{#if marker && time(marker.date) >= from && time(marker.date) <= until}
|
||||
<line
|
||||
x1={x(marker.date)}
|
||||
x2={x(marker.date)}
|
||||
y1={plot.top - 6}
|
||||
y2={height - plot.bottom}
|
||||
stroke="var(--site-muted)"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="2 3"
|
||||
/>
|
||||
<text
|
||||
x={x(marker.date)}
|
||||
y={plot.top - 8}
|
||||
text-anchor="middle"
|
||||
fill="var(--site-muted)"
|
||||
font-size="11">{marker.label}</text
|
||||
>
|
||||
{/if}
|
||||
{#each series as item (item.label)}
|
||||
{#if item.points.length}
|
||||
<path
|
||||
d={path(item.points)}
|
||||
fill="none"
|
||||
stroke={item.color}
|
||||
stroke-width={item.emphasis ? 3.5 : 2}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{#if item.points.length === 1}
|
||||
<circle
|
||||
cx={x(item.points[0].date)}
|
||||
cy={y(item.points[0].value)}
|
||||
r={item.emphasis ? 4 : 3}
|
||||
fill={item.color}
|
||||
{@const parts = segments(item)}
|
||||
{#each [parts.solid, parts.dashed] as points, index (index)}
|
||||
{#if points.length > 1}
|
||||
<path
|
||||
d={path(points)}
|
||||
fill="none"
|
||||
stroke={item.color}
|
||||
stroke-width={item.emphasis ? 3 : 1.75}
|
||||
stroke-dasharray={index ? '6 5' : undefined}
|
||||
stroke-linecap={index ? 'butt' : 'round'}
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if item.points.length === 1}
|
||||
<circle
|
||||
cx={x(item.points[0].date)}
|
||||
cy={y(item.points[0].value)}
|
||||
r="4"
|
||||
fill={item.color}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
<text x={plot.left} y={height - 8} fill="var(--site-muted)" font-size="11"
|
||||
>{dateLabel(from)}</text
|
||||
>
|
||||
<text
|
||||
x={width - plot.right}
|
||||
y={height - 8}
|
||||
text-anchor="end"
|
||||
fill="var(--site-muted)"
|
||||
font-size="11">{dateLabel(until)}</text
|
||||
>
|
||||
{#if hoverDate}
|
||||
<line
|
||||
x1={x(hoverDate)}
|
||||
x2={x(hoverDate)}
|
||||
y1={plot.top}
|
||||
y2={height - plot.bottom}
|
||||
stroke="var(--site-muted)"
|
||||
stroke-width="1"
|
||||
/>
|
||||
{#each hoverRows as row (row.item.label)}
|
||||
<circle
|
||||
cx={x(hoverDate)}
|
||||
cy={y(row.point.value)}
|
||||
r="4"
|
||||
fill={row.item.color}
|
||||
stroke="var(--site-bg, #000)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
<rect
|
||||
x={plot.left}
|
||||
y={plot.top}
|
||||
width={width - plot.left - plot.right}
|
||||
height={height - plot.top - plot.bottom}
|
||||
fill="transparent"
|
||||
/>
|
||||
</svg>
|
||||
{#if hoverDate && hoverRows.length}
|
||||
<div
|
||||
class="tooltip"
|
||||
class:flip={x(hoverDate) > width / 2}
|
||||
style={`left:${x(hoverDate)}px`}
|
||||
role="status"
|
||||
>
|
||||
<p class="tooltip-date">
|
||||
{dateLabel(hoverDate, true)}{#if marker && hoverDate > marker.date} · projected{/if}
|
||||
</p>
|
||||
{#each hoverRows as row (row.item.label)}
|
||||
<p class:emphasis={row.item.emphasis}>
|
||||
<span class="swatch" style={`background:${row.item.color}`}></span>
|
||||
<span class="tooltip-label">{row.item.label}</span>
|
||||
<span class="tooltip-value">{format(row.point.value, true)}</span>
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if series.length > 1}
|
||||
{#if series.length > 1 || series.some((item) => item.dashedFrom)}
|
||||
<figcaption class="legend">
|
||||
{#each series as item (item.label)}
|
||||
<span class:emphasis={item.emphasis}
|
||||
><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span
|
||||
>
|
||||
{/each}
|
||||
{#if series.some((item) => item.dashedFrom)}
|
||||
<span class="line-key first"><span class="key solid"></span>Actual</span>
|
||||
<span class="line-key"><span class="key dashed"></span>Projected</span>
|
||||
{/if}
|
||||
</figcaption>
|
||||
{/if}
|
||||
{:else}
|
||||
@@ -124,14 +283,54 @@
|
||||
.chart {
|
||||
min-width: 0;
|
||||
}
|
||||
.chart-scroll {
|
||||
overflow-x: auto;
|
||||
.chart-area {
|
||||
position: relative;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 540px;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
user-select: none;
|
||||
}
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
z-index: 2;
|
||||
min-width: 170px;
|
||||
max-width: 240px;
|
||||
margin-left: 12px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 7px;
|
||||
background: var(--site-bg, #000);
|
||||
box-shadow: 0 6px 20px rgb(0 0 0 / 0.25);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tooltip.flip {
|
||||
transform: translateX(calc(-100% - 24px));
|
||||
}
|
||||
.tooltip p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
color: var(--site-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.tooltip p.emphasis {
|
||||
color: var(--site-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tooltip .tooltip-date {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.tooltip-value {
|
||||
margin-left: auto;
|
||||
color: var(--site-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
@@ -147,16 +346,27 @@
|
||||
gap: 7px;
|
||||
}
|
||||
.legend > span.emphasis {
|
||||
order: -1;
|
||||
flex-basis: 100%;
|
||||
color: var(--site-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.legend > .line-key.first {
|
||||
margin-left: auto;
|
||||
}
|
||||
.key {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
border-top: 2px solid var(--site-muted);
|
||||
}
|
||||
.key.dashed {
|
||||
border-top-style: dashed;
|
||||
}
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px var(--site-border);
|
||||
}
|
||||
.empty {
|
||||
padding: 70px 0;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
accountBalances,
|
||||
accountColor,
|
||||
balanceSeries,
|
||||
forecastSeries,
|
||||
utilityEstimate,
|
||||
wealthProjection
|
||||
wealthProjection,
|
||||
wealthTimeline
|
||||
} from './charts';
|
||||
|
||||
describe('money tracker calculations', () => {
|
||||
@@ -31,6 +33,18 @@ describe('money tracker calculations', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses an account's chosen color and falls back to the palette", () => {
|
||||
const series = balanceSeries(
|
||||
[
|
||||
{ id: 1, name: 'Revolut', openingBalanceCents: 0, color: '#000000' },
|
||||
{ id: 2, name: 'Knab', openingBalanceCents: 0, color: null }
|
||||
],
|
||||
[{ accountId: 1, date: '2026-01-01', amountCents: 100 }]
|
||||
);
|
||||
expect(series[0].color).toBe('#000000');
|
||||
expect(series[1].color).toBe(accountColor({ color: null }, 1));
|
||||
});
|
||||
|
||||
it('plots combined transactions in date order even if the input is unordered', () => {
|
||||
const combined = balanceSeries(
|
||||
[{ id: 1, name: 'Cash', openingBalanceCents: 1000 }],
|
||||
@@ -113,6 +127,108 @@ describe('money tracker calculations', () => {
|
||||
expect(series[0].points.at(-1)?.value).toBe(15000);
|
||||
});
|
||||
|
||||
it('invests excess each month without requiring an existing investment account', () => {
|
||||
const projection = wealthProjection(
|
||||
[{ id: 1, kind: 'spendable', openingBalanceCents: 40054 }],
|
||||
new Map([[1, 40054]]),
|
||||
[],
|
||||
[],
|
||||
[{ amountCents: 127137, cadence: 'monthly', accountId: 1 }],
|
||||
{
|
||||
investmentAccountId: null,
|
||||
reserveCents: 100000,
|
||||
annualReturnPercent: '5.00',
|
||||
contributionMode: 'excess',
|
||||
fixedMonthlyCents: 0,
|
||||
includeRecurring: 1,
|
||||
endDate: '2026-11-30'
|
||||
},
|
||||
'2026-09-23'
|
||||
);
|
||||
expect(projection[0].points.map((point) => point.value)).toEqual([
|
||||
40054, 40054, 167191, 294602
|
||||
]);
|
||||
expect(projection[1].points.at(-1)?.value).toBe(100000);
|
||||
expect(projection[2].points.at(-1)?.value).toBe(194602);
|
||||
});
|
||||
|
||||
it('continues actual history into the projection as one dashed line', () => {
|
||||
const accounts = [
|
||||
{ id: 1, name: 'Bank', kind: 'spendable', openingBalanceCents: 0 },
|
||||
{ id: 2, name: 'Broker', kind: 'investment', openingBalanceCents: 0 }
|
||||
];
|
||||
const actual = [
|
||||
{ accountId: 1, date: '2026-09-01', amountCents: 50000 },
|
||||
{ accountId: 2, date: '2026-09-10', amountCents: 20000 }
|
||||
];
|
||||
const projection = [
|
||||
{
|
||||
label: 'Total wealth',
|
||||
color: '',
|
||||
points: [
|
||||
{ date: '2026-09-23', value: 70000 },
|
||||
{ date: '2026-10-31', value: 90000 }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Spendable',
|
||||
color: '',
|
||||
points: [
|
||||
{ date: '2026-09-23', value: 50000 },
|
||||
{ date: '2026-10-31', value: 60000 }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Invested',
|
||||
color: '',
|
||||
points: [
|
||||
{ date: '2026-09-23', value: 20000 },
|
||||
{ date: '2026-10-31', value: 30000 }
|
||||
]
|
||||
}
|
||||
];
|
||||
const [total] = wealthTimeline(accounts, actual, projection, '2026-09-23', 'total');
|
||||
expect(total.dashedFrom).toBe('2026-09-23');
|
||||
expect(total.points).toEqual([
|
||||
{ date: '2026-09-01', value: 50000 },
|
||||
{ date: '2026-09-10', value: 70000 },
|
||||
{ date: '2026-09-23', value: 70000 },
|
||||
{ date: '2026-10-31', value: 90000 }
|
||||
]);
|
||||
const [, spendable, invested] = wealthTimeline(
|
||||
accounts,
|
||||
actual,
|
||||
projection,
|
||||
'2026-09-23',
|
||||
'split'
|
||||
);
|
||||
expect(spendable.points.map((point) => point.value)).toEqual([50000, 50000, 50000, 60000]);
|
||||
expect(invested.points.map((point) => point.value)).toEqual([0, 20000, 20000, 30000]);
|
||||
const byAccount = wealthTimeline(accounts, actual, projection, '2026-09-23', 'accounts');
|
||||
expect(byAccount.map((line) => line.label)).toEqual(['Bank', 'Broker', 'Total']);
|
||||
expect(byAccount[0].points.at(-1)).toEqual({ date: '2026-09-23', value: 50000 });
|
||||
});
|
||||
|
||||
it('starts the timeline at today when there is no history', () => {
|
||||
const [total] = wealthTimeline(
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
label: 'Total wealth',
|
||||
color: '',
|
||||
points: [
|
||||
{ date: '2026-09-23', value: 0 },
|
||||
{ date: '2026-10-31', value: 100 }
|
||||
]
|
||||
}
|
||||
],
|
||||
'2026-09-23',
|
||||
'total'
|
||||
);
|
||||
expect(total.points.map((point) => point.date)).toEqual(['2026-09-23', '2026-10-31']);
|
||||
});
|
||||
|
||||
it('compares estimated usage cost with prorated advances', () => {
|
||||
const result = utilityEstimate(
|
||||
[
|
||||
|
||||
@@ -4,13 +4,21 @@ export type ChartSeries = {
|
||||
color: string;
|
||||
points: ChartPoint[];
|
||||
emphasis?: boolean;
|
||||
/** Points after this date are drawn dashed (projected). */
|
||||
dashedFrom?: string;
|
||||
};
|
||||
|
||||
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
|
||||
|
||||
/** The account's chosen color, or a palette color based on its position in the list. */
|
||||
export function accountColor(account: { color?: string | null }, index: number): string {
|
||||
return account.color || palette[index % palette.length];
|
||||
}
|
||||
|
||||
export function balanceSeries(
|
||||
accounts: { id: number; name: string; openingBalanceCents: number }[],
|
||||
accounts: { id: number; name: string; openingBalanceCents: number; color?: string | null }[],
|
||||
transactions: { accountId: number; date: string; amountCents: number }[]
|
||||
): ChartSeries[] {
|
||||
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
|
||||
const ordered = [...transactions].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const balances = new Map(accounts.map((account) => [account.id, account.openingBalanceCents]));
|
||||
const accountPoints = new Map(accounts.map((account) => [account.id, [] as ChartPoint[]]));
|
||||
@@ -34,7 +42,7 @@ export function balanceSeries(
|
||||
return [
|
||||
...accounts.map((account, index) => ({
|
||||
label: account.name,
|
||||
color: palette[index % palette.length],
|
||||
color: accountColor(account, index),
|
||||
points: accountPoints.get(account.id)!
|
||||
})),
|
||||
{ label: 'All accounts', color: 'var(--site-muted)', points: combinedPoints }
|
||||
@@ -163,7 +171,7 @@ export function wealthProjection(
|
||||
} else spendable += value;
|
||||
}
|
||||
}
|
||||
if (validInvestmentId !== null && index > 0) {
|
||||
if (index > 0) {
|
||||
const growth = Math.round(indexBalance * monthlyGrowth);
|
||||
invested += growth;
|
||||
indexBalance += growth;
|
||||
@@ -194,6 +202,82 @@ export function wealthProjection(
|
||||
];
|
||||
}
|
||||
|
||||
export type TimelineView = 'total' | 'split' | 'accounts';
|
||||
|
||||
/**
|
||||
* Joins actual history (solid, through today) with the wealth projection (dashed, after
|
||||
* today) so both read as one continuous line per measure.
|
||||
*/
|
||||
export function wealthTimeline(
|
||||
accounts: {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: string;
|
||||
openingBalanceCents: number;
|
||||
color?: string | null;
|
||||
}[],
|
||||
actualTransactions: { accountId: number; date: string; amountCents: number }[],
|
||||
projection: ChartSeries[],
|
||||
today: string,
|
||||
view: TimelineView
|
||||
): ChartSeries[] {
|
||||
const history = balanceSeries(accounts, actualTransactions);
|
||||
const accountLines = history.slice(0, accounts.length);
|
||||
const dates = history.at(-1)!.points.map((point) => point.date);
|
||||
const sumKind = (investment: boolean) =>
|
||||
dates.map((date, index) => ({
|
||||
date,
|
||||
value: accounts.reduce(
|
||||
(sum, account, accountIndex) =>
|
||||
(account.kind === 'investment') === investment
|
||||
? sum + accountLines[accountIndex].points[index].value
|
||||
: sum,
|
||||
0
|
||||
)
|
||||
}));
|
||||
const untilToday = (points: ChartPoint[]) => {
|
||||
const last = points.at(-1);
|
||||
return last && last.date < today ? [...points, { date: today, value: last.value }] : points;
|
||||
};
|
||||
const join = (actual: ChartPoint[], projected: ChartPoint[]) => {
|
||||
const solid = untilToday(actual);
|
||||
const cutoff = solid.at(-1)?.date ?? '';
|
||||
return [...solid, ...projected.filter((point) => point.date > cutoff)];
|
||||
};
|
||||
const [totalProjection, spendableProjection, investedProjection] = projection;
|
||||
const total: ChartSeries = {
|
||||
label: 'Total',
|
||||
color: 'var(--site-text)',
|
||||
emphasis: true,
|
||||
dashedFrom: today,
|
||||
points: join(history.at(-1)!.points, totalProjection?.points ?? [])
|
||||
};
|
||||
if (view === 'accounts')
|
||||
return [
|
||||
...accountLines
|
||||
.filter((line) => line.points.some((point) => point.value !== 0))
|
||||
.map((line) => ({ ...line, points: untilToday(line.points) })),
|
||||
total
|
||||
];
|
||||
if (view === 'split')
|
||||
return [
|
||||
total,
|
||||
{
|
||||
label: 'Spendable',
|
||||
color: '#46a758',
|
||||
dashedFrom: today,
|
||||
points: join(sumKind(false), spendableProjection?.points ?? [])
|
||||
},
|
||||
{
|
||||
label: 'Invested',
|
||||
color: '#8e4ec6',
|
||||
dashedFrom: today,
|
||||
points: join(sumKind(true), investedProjection?.points ?? [])
|
||||
}
|
||||
];
|
||||
return [total];
|
||||
}
|
||||
|
||||
export function utilityEstimate(
|
||||
readings: { date: string; reading: string; kind: string }[],
|
||||
kind: string,
|
||||
|
||||
@@ -6,6 +6,7 @@ export const lists = pgTable('lists', {
|
||||
id: id(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
pictureUrl: text('picture_url'),
|
||||
isProgress: boolean('is_progress').notNull().default(false),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export const moneyAccounts = pgTable(
|
||||
name: text('name').notNull(),
|
||||
openingBalanceCents: integer('opening_balance_cents').notNull().default(0),
|
||||
kind: text('kind').notNull().default('spendable'),
|
||||
color: text('color'),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [unique().on(table.userId, table.name), index().on(table.userId)]
|
||||
|
||||
Reference in New Issue
Block a user