add icons to lists and solve money tracker bugs
All checks were successful
Test and deploy staging / staging (push) Successful in 2m30s

This commit is contained in:
2026-09-24 19:24:09 +00:00
parent a10585a4d2
commit c757d0882a
18 changed files with 1358 additions and 731 deletions

0
eslint.config.js Normal file → Executable file
View File

View File

@@ -9,6 +9,7 @@ WHERE slug IS NULL;
ALTER TABLE lists ALTER COLUMN slug SET NOT NULL; ALTER TABLE lists ALTER COLUMN slug SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS lists_slug_unique ON lists (slug); CREATE UNIQUE INDEX IF NOT EXISTS lists_slug_unique ON lists (slug);
ALTER TABLE lists ADD COLUMN IF NOT EXISTS is_progress boolean NOT NULL DEFAULT false; ALTER TABLE lists ADD COLUMN IF NOT EXISTS is_progress boolean NOT NULL DEFAULT false;
ALTER TABLE lists ADD COLUMN IF NOT EXISTS picture_url text;
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS picture_url text; ALTER TABLE list_items ADD COLUMN IF NOT EXISTS picture_url text;
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS description text; ALTER TABLE list_items ADD COLUMN IF NOT EXISTS description text;

View File

@@ -0,0 +1 @@
ALTER TABLE lists ADD COLUMN IF NOT EXISTS picture_url text;

View File

@@ -116,3 +116,5 @@ CREATE TABLE IF NOT EXISTS money_insurances (
updated_at timestamptz NOT NULL DEFAULT now() updated_at timestamptz NOT NULL DEFAULT now()
); );
CREATE INDEX IF NOT EXISTS money_insurances_user_id_idx ON money_insurances (user_id); CREATE INDEX IF NOT EXISTS money_insurances_user_id_idx ON money_insurances (user_id);
ALTER TABLE money_accounts ADD COLUMN IF NOT EXISTS color text;

View File

@@ -1,35 +1,74 @@
<script lang="ts"> <script lang="ts">
import type { ChartSeries } from './charts'; import type { ChartPoint, ChartSeries } from './charts';
let { let {
series, series,
title, title,
unit = '€' unit = '€',
}: { series: ChartSeries[]; title: string; unit?: string } = $props(); marker
const width = 800; }: {
const height = 260; series: ChartSeries[];
const plot = { left: 72, right: 18, top: 14, bottom: 38 }; 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 values = $derived(series.flatMap((item) => item.points.map((point) => point.value)));
let dates = $derived( let times = $derived(series.flatMap((item) => item.points.map((point) => time(point.date))));
series.flatMap((item) => item.points.map((point) => Date.parse(`${point.date}T12:00:00Z`))) let scale = $derived(niceScale(Math.min(0, ...values), Math.max(0, ...values)));
); let min = $derived(scale.min);
let min = $derived(Math.min(0, ...values)); let max = $derived(scale.max);
let max = $derived(Math.max(0, ...values));
let span = $derived(max - min || 1); let span = $derived(max - min || 1);
let from = $derived(Math.min(...dates)); let from = $derived(Math.min(...times));
let until = $derived(Math.max(...dates)); let until = $derived(Math.max(...times));
let dateSpan = $derived(until - from || 1); let dateSpan = $derived(until - from || 1);
const money = new Intl.NumberFormat('en-NL', { maximumFractionDigits: 0 }); let snapDates = $derived(
function x(date: string) { [...new Set(series.flatMap((item) => item.points.map((point) => point.date)))].sort()
return (
plot.left +
((Date.parse(`${date}T12:00:00Z`) - from) / dateSpan) * (width - plot.left - plot.right)
); );
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 + ((time(date) - from) / dateSpan) * (width - plot.left - plot.right);
} }
function y(value: number) { function y(value: number) {
return plot.top + (1 - (value - min) / span) * (height - plot.top - plot.bottom); return plot.top + (1 - (value - min) / span) * (height - plot.top - plot.bottom);
} }
function path(points: ChartSeries['points']) { function path(points: ChartPoint[]) {
return points return points
.map( .map(
(point, index) => (point, index) =>
@@ -37,26 +76,76 @@
) )
.join(' '); .join(' ');
} }
function dateLabel(value: number) { /** Solid part through `dashedFrom` and dashed part from the last solid point onward. */
return new Date(value).toLocaleDateString('en-NL', { 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', month: 'short',
year: 'numeric', year: withDay ? 'numeric' : '2-digit',
timeZone: 'UTC' 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> </script>
<figure class="chart"> <figure class="chart">
{#if values.length} {#if values.length}
<div class="chart-scroll" role="region" aria-label={title}> <div class="chart-area" bind:clientWidth>
<svg <svg
viewBox={`0 0 ${width} ${height}`} viewBox={`0 0 ${width} ${height}`}
width={clientWidth ? undefined : width}
role="img" role="img"
aria-label={title} aria-label={title}
preserveAspectRatio="xMidYMid meet" onpointermove={hover}
onpointerdown={hover}
onpointerleave={() => (hoverDate = null)}
> >
{#each [0, 0.5, 1] as fraction (fraction)} {#each scale.ticks as value (value)}
{@const value = max - span * fraction}
<line <line
x1={plot.left} x1={plot.left}
x2={width - plot.right} x2={width - plot.right}
@@ -66,53 +155,123 @@
stroke-width="1" stroke-width="1"
/> />
<text <text
x={plot.left - 12} x={plot.left - 8}
y={y(value) + 4} y={y(value) + 4}
text-anchor="end" text-anchor="end"
fill="var(--site-muted)" fill="var(--site-muted)"
font-size="11">{unit}{money.format(value / (unit === '€' ? 100 : 1))}</text font-size="11">{format(value)}</text
> >
{/each} {/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)} {#each series as item (item.label)}
{#if item.points.length} {@const parts = segments(item)}
{#each [parts.solid, parts.dashed] as points, index (index)}
{#if points.length > 1}
<path <path
d={path(item.points)} d={path(points)}
fill="none" fill="none"
stroke={item.color} stroke={item.color}
stroke-width={item.emphasis ? 3.5 : 2} stroke-width={item.emphasis ? 3 : 1.75}
stroke-linecap="round" stroke-dasharray={index ? '6 5' : undefined}
stroke-linecap={index ? 'butt' : 'round'}
stroke-linejoin="round" stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/> />
{/if}
{/each}
{#if item.points.length === 1} {#if item.points.length === 1}
<circle <circle
cx={x(item.points[0].date)} cx={x(item.points[0].date)}
cy={y(item.points[0].value)} cy={y(item.points[0].value)}
r={item.emphasis ? 4 : 3} r="4"
fill={item.color} fill={item.color}
/> />
{/if} {/if}
{/if}
{/each} {/each}
<text x={plot.left} y={height - 8} fill="var(--site-muted)" font-size="11" {#if hoverDate}
>{dateLabel(from)}</text <line
> x1={x(hoverDate)}
<text x2={x(hoverDate)}
x={width - plot.right} y1={plot.top}
y={height - 8} y2={height - plot.bottom}
text-anchor="end" stroke="var(--site-muted)"
fill="var(--site-muted)" stroke-width="1"
font-size="11">{dateLabel(until)}</text />
> {#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> </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}&nbsp;· 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> </div>
{#if series.length > 1} {/if}
</div>
{#if series.length > 1 || series.some((item) => item.dashedFrom)}
<figcaption class="legend"> <figcaption class="legend">
{#each series as item (item.label)} {#each series as item (item.label)}
<span class:emphasis={item.emphasis} <span class:emphasis={item.emphasis}
><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span ><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span
> >
{/each} {/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> </figcaption>
{/if} {/if}
{:else} {:else}
@@ -124,14 +283,54 @@
.chart { .chart {
min-width: 0; min-width: 0;
} }
.chart-scroll { .chart-area {
overflow-x: auto; position: relative;
touch-action: pan-y;
} }
svg { svg {
display: block; display: block;
width: 100%; width: 100%;
min-width: 540px;
height: auto; 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 { .legend {
display: flex; display: flex;
@@ -147,16 +346,27 @@
gap: 7px; gap: 7px;
} }
.legend > span.emphasis { .legend > span.emphasis {
order: -1;
flex-basis: 100%;
color: var(--site-text); color: var(--site-text);
font-weight: 600; 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 { .swatch {
display: inline-block; display: inline-block;
flex: none;
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 0 1px var(--site-border);
} }
.empty { .empty {
padding: 70px 0; padding: 70px 0;

View File

@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
accountBalances, accountBalances,
accountColor,
balanceSeries, balanceSeries,
forecastSeries, forecastSeries,
utilityEstimate, utilityEstimate,
wealthProjection wealthProjection,
wealthTimeline
} from './charts'; } from './charts';
describe('money tracker calculations', () => { 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', () => { it('plots combined transactions in date order even if the input is unordered', () => {
const combined = balanceSeries( const combined = balanceSeries(
[{ id: 1, name: 'Cash', openingBalanceCents: 1000 }], [{ id: 1, name: 'Cash', openingBalanceCents: 1000 }],
@@ -113,6 +127,108 @@ describe('money tracker calculations', () => {
expect(series[0].points.at(-1)?.value).toBe(15000); 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', () => { it('compares estimated usage cost with prorated advances', () => {
const result = utilityEstimate( const result = utilityEstimate(
[ [

View File

@@ -4,13 +4,21 @@ export type ChartSeries = {
color: string; color: string;
points: ChartPoint[]; points: ChartPoint[];
emphasis?: boolean; 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( 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 }[] transactions: { accountId: number; date: string; amountCents: number }[]
): ChartSeries[] { ): ChartSeries[] {
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
const ordered = [...transactions].sort((a, b) => a.date.localeCompare(b.date)); const ordered = [...transactions].sort((a, b) => a.date.localeCompare(b.date));
const balances = new Map(accounts.map((account) => [account.id, account.openingBalanceCents])); const balances = new Map(accounts.map((account) => [account.id, account.openingBalanceCents]));
const accountPoints = new Map(accounts.map((account) => [account.id, [] as ChartPoint[]])); const accountPoints = new Map(accounts.map((account) => [account.id, [] as ChartPoint[]]));
@@ -34,7 +42,7 @@ export function balanceSeries(
return [ return [
...accounts.map((account, index) => ({ ...accounts.map((account, index) => ({
label: account.name, label: account.name,
color: palette[index % palette.length], color: accountColor(account, index),
points: accountPoints.get(account.id)! points: accountPoints.get(account.id)!
})), })),
{ label: 'All accounts', color: 'var(--site-muted)', points: combinedPoints } { label: 'All accounts', color: 'var(--site-muted)', points: combinedPoints }
@@ -163,7 +171,7 @@ export function wealthProjection(
} else spendable += value; } else spendable += value;
} }
} }
if (validInvestmentId !== null && index > 0) { if (index > 0) {
const growth = Math.round(indexBalance * monthlyGrowth); const growth = Math.round(indexBalance * monthlyGrowth);
invested += growth; invested += growth;
indexBalance += 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( export function utilityEstimate(
readings: { date: string; reading: string; kind: string }[], readings: { date: string; reading: string; kind: string }[],
kind: string, kind: string,

View File

@@ -6,6 +6,7 @@ export const lists = pgTable('lists', {
id: id(), id: id(),
name: text('name').notNull(), name: text('name').notNull(),
slug: text('slug').notNull().unique(), slug: text('slug').notNull().unique(),
pictureUrl: text('picture_url'),
isProgress: boolean('is_progress').notNull().default(false), isProgress: boolean('is_progress').notNull().default(false),
...timestamps ...timestamps
}); });

View File

@@ -12,6 +12,7 @@ export const moneyAccounts = pgTable(
name: text('name').notNull(), name: text('name').notNull(),
openingBalanceCents: integer('opening_balance_cents').notNull().default(0), openingBalanceCents: integer('opening_balance_cents').notNull().default(0),
kind: text('kind').notNull().default('spendable'), kind: text('kind').notNull().default('spendable'),
color: text('color'),
...timestamps ...timestamps
}, },
(table) => [unique().on(table.userId, table.name), index().on(table.userId)] (table) => [unique().on(table.userId, table.name), index().on(table.userId)]

View File

@@ -6,7 +6,7 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
const lists = await db.query.lists.findMany({ const lists = await db.query.lists.findMany({
columns: { id: true, name: true, slug: true, isProgress: true }, columns: { id: true, name: true, slug: true, isProgress: true, pictureUrl: true },
with: { items: { columns: { id: true } } }, with: { items: { columns: { id: true } } },
orderBy: { id: 'desc' } orderBy: { id: 'desc' }
}); });

View File

@@ -138,7 +138,17 @@
class="block py-4 hover:text-[var(--site-muted)]" class="block py-4 hover:text-[var(--site-muted)]"
> >
<span class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1"> <span class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1">
<span class="flex items-center gap-3">
{#if list.pictureUrl}<img
src={list.pictureUrl}
alt=""
loading="lazy"
width="40"
height="40"
class="h-10 w-10 rounded bg-[var(--site-surface)] object-cover"
/>{/if}
<span>{list.name}</span> <span>{list.name}</span>
</span>
<span class="inline-flex items-center gap-1 text-base text-[var(--site-muted)]"> <span class="inline-flex items-center gap-1 text-base text-[var(--site-muted)]">
{#if list.isProgress && data.user} {#if list.isProgress && data.user}
{list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total} {list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total}

View File

@@ -2,6 +2,7 @@ import { db } from '$lib/server/db';
import { listItemCompletions, lists } from '$lib/server/db/schema'; import { listItemCompletions, lists } from '$lib/server/db/schema';
import { requireAdmin } from '$lib/server/authorization'; import { requireAdmin } from '$lib/server/authorization';
import { uniqueListSlug } from '$lib/server/slugify'; import { uniqueListSlug } from '$lib/server/slugify';
import { listImageUrl } from '$lib/server/r2';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@@ -36,12 +37,18 @@ export const actions: Actions = {
const form = await event.request.formData(); const form = await event.request.formData();
const name = String(form.get('name') ?? '').trim(); const name = String(form.get('name') ?? '').trim();
const type = String(form.get('type') ?? ''); const type = String(form.get('type') ?? '');
const pictureKey = String(form.get('pictureKey') ?? '').trim();
const picture = form.get('picture');
if (!name || name.length > 200) if (!name || name.length > 200)
return fail(400, { message: 'Enter a list name of 1–200 characters.' }); return fail(400, { message: 'Enter a list name of 1–200 characters.' });
if (type !== 'normal' && type !== 'progress') if (type !== 'normal' && type !== 'progress')
return fail(400, { message: 'Choose a list type.' }); return fail(400, { message: 'Choose a list type.' });
if (picture instanceof File && picture.size > 0)
return fail(400, { message: 'Enable JavaScript to upload an image.' });
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
const slug = await uniqueListSlug(name); const slug = await uniqueListSlug(name);
await db.insert(lists).values({ name, slug, isProgress: type === 'progress' }); await db.insert(lists).values({ name, slug, isProgress: type === 'progress', pictureUrl });
redirect(303, `/lists/${slug}`); redirect(303, `/lists/${slug}`);
} }
}; };

View File

@@ -2,8 +2,42 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import ProgressBar from '$lib/components/ProgressBar.svelte'; import ProgressBar from '$lib/components/ProgressBar.svelte';
import ListPictureDropzone from '$lib/components/ListPictureDropzone.svelte';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
let uploadError = $state('');
let uploading = $state(false);
const uploadPicture: SubmitFunction = async ({ formData, cancel }) => {
uploadError = '';
const picture = formData.get('picture');
formData.delete('picture');
if (!(picture instanceof File) || !picture.size) return;
if (!['image/jpeg', 'image/png', 'image/webp'].includes(picture.type)) {
uploadError = 'Choose a JPEG, PNG, or WebP image.';
cancel();
return;
}
if (picture.size > 5 * 1024 * 1024) {
uploadError = 'Images must be 5 MB or smaller.';
cancel();
return;
}
uploading = true;
try {
const body = new FormData();
body.set('picture', picture);
const response = await fetch(resolve('/lists/api/upload'), { method: 'POST', body });
if (!response.ok) throw new Error('Could not upload the image. Please try again.');
const { key } = await response.json();
formData.set('pictureKey', key);
} catch (cause) {
uploadError = cause instanceof Error ? cause.message : 'Image upload failed.';
cancel();
} finally {
uploading = false;
}
};
</script> </script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16"> <div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
@@ -14,7 +48,8 @@
<form <form
method="POST" method="POST"
action="?/newList" action="?/newList"
use:enhance enctype="multipart/form-data"
use:enhance={uploadPicture}
class="absolute top-full right-0 z-10 mt-3 w-80 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg" class="absolute top-full right-0 z-10 mt-3 w-80 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg"
> >
<label class="block" <label class="block"
@@ -33,7 +68,9 @@
></select ></select
></label ></label
> >
<ListPictureDropzone label="List picture (optional)" />
<button <button
disabled={uploading}
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)]" class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)]"
>create list</button >create list</button
> >
@@ -41,6 +78,7 @@
</details>{/if} </details>{/if}
</div> </div>
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if} {#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
{#if uploadError}<p role="alert" class="mb-5 text-red-500">{uploadError}</p>{/if}
<ul> <ul>
{#each data.lists as list (list.id)} {#each data.lists as list (list.id)}
<li class="border-t border-[var(--site-border)]"> <li class="border-t border-[var(--site-border)]">
@@ -49,7 +87,17 @@
class="block py-5 hover:text-[var(--site-muted)]" class="block py-5 hover:text-[var(--site-muted)]"
> >
<span class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1"> <span class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1">
<span class="flex items-center gap-3">
{#if list.pictureUrl}<img
src={list.pictureUrl}
alt=""
loading="lazy"
width="48"
height="48"
class="h-12 w-12 rounded bg-[var(--site-surface)] object-cover"
/>{/if}
<span class="text-lg font-medium">{list.name}</span> <span class="text-lg font-medium">{list.name}</span>
</span>
<span class="text-base text-[var(--site-muted)]"> <span class="text-base text-[var(--site-muted)]">
{#if list.isProgress && data.user} {#if list.isProgress && data.user}
{list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total} {list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total}

View File

@@ -1,4 +1,5 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { env } from '$env/dynamic/private';
import { lists, listItems, listItemCompletions } from '$lib/server/db/schema'; import { lists, listItems, listItemCompletions } from '$lib/server/db/schema';
import { requireAdmin, requireUser } from '$lib/server/authorization'; import { requireAdmin, requireUser } from '$lib/server/authorization';
import { deleteObject, listImageKey, listImageUrl } from '$lib/server/r2'; import { deleteObject, listImageKey, listImageUrl } from '$lib/server/r2';
@@ -13,6 +14,8 @@ async function currentList(slug: string) {
return list; return list;
} }
async function removeStoredPicture(url: string | null) { async function removeStoredPicture(url: string | null) {
// Development and staging may reference pictures copied from production.
if (env.ORIGIN !== 'https://stanrunge.dev') return;
const key = url ? listImageKey(url) : null; const key = url ? listImageKey(url) : null;
if (!key) return; if (!key) return;
try { try {
@@ -79,7 +82,9 @@ export const actions: Actions = {
.from(listItems) .from(listItems)
.where(eq(listItems.listId, list.id)); .where(eq(listItems.listId, list.id));
await db.delete(lists).where(eq(lists.id, list.id)); await db.delete(lists).where(eq(lists.id, list.id));
await Promise.all(pictures.map((item) => removeStoredPicture(item.pictureUrl))); await Promise.all(
[...pictures.map((item) => item.pictureUrl), list.pictureUrl].map(removeStoredPicture)
);
redirect(303, '/lists'); redirect(303, '/lists');
}, },
updateList: async (event) => { updateList: async (event) => {
@@ -90,12 +95,21 @@ export const actions: Actions = {
.trim() .trim()
.toLowerCase(); .toLowerCase();
const type = String(form.get('type') ?? ''); const type = String(form.get('type') ?? '');
const pictureKey = String(form.get('pictureKey') ?? '').trim();
const removePicture = form.get('removePicture') === 'on';
const picture = form.get('picture');
if (!name || name.length > 200) if (!name || name.length > 200)
return fail(400, { message: 'Enter a name of 1–200 characters' }); return fail(400, { message: 'Enter a name of 1–200 characters' });
if (type !== 'normal' && type !== 'progress') if (type !== 'normal' && type !== 'progress')
return fail(400, { message: 'Choose a list type.' }); return fail(400, { message: 'Choose a list type.' });
if (!validSlug(slug)) if (!validSlug(slug))
return fail(400, { message: 'Use a lowercase slug with letters, numbers, and hyphens.' }); return fail(400, { message: 'Use a lowercase slug with letters, numbers, and hyphens.' });
if (picture instanceof File && picture.size > 0)
return fail(400, { message: 'Enable JavaScript to upload an image.' });
if (pictureKey && removePicture)
return fail(400, { message: 'Choose a new picture or remove the old one.' });
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
const current = await currentList(event.params.slug); const current = await currentList(event.params.slug);
const duplicate = await db const duplicate = await db
.select({ id: lists.id }) .select({ id: lists.id })
@@ -104,10 +118,16 @@ export const actions: Actions = {
if (duplicate.length) return fail(409, { message: 'That list slug is already in use.' }); if (duplicate.length) return fail(409, { message: 'That list slug is already in use.' });
const [list] = await db const [list] = await db
.update(lists) .update(lists)
.set({ name, slug, isProgress: type === 'progress' }) .set({
name,
slug,
isProgress: type === 'progress',
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
})
.where(eq(lists.id, current.id)) .where(eq(lists.id, current.id))
.returning(); .returning();
if (!list) error(404, 'List not found'); if (!list) error(404, 'List not found');
if (list.pictureUrl !== current.pictureUrl) await removeStoredPicture(current.pictureUrl);
if (slug !== event.params.slug) redirect(303, `/lists/${slug}`); if (slug !== event.params.slug) redirect(303, `/lists/${slug}`);
return { saved: true }; return { saved: true };
}, },

View File

@@ -12,11 +12,28 @@
let uploadError = $state(''); let uploadError = $state('');
let uploading = $state(false); let uploading = $state(false);
let managing = $state(false); let managing = $state(false);
let search = $state('');
let editingItem = $state<(typeof data.list.items)[number] | null>(null);
let visibleItems = $derived(
data.list.items.filter((item) =>
`${item.name} ${item.description ?? ''}`.toLowerCase().includes(search.trim().toLowerCase())
)
);
const uploadPicture: SubmitFunction = async ({ formData, cancel }) => { const uploadPicture: SubmitFunction = async ({ formData, cancel }) => {
const finish = async ({
result,
update
}: {
result: { type: string };
update: () => Promise<void>;
}) => {
await update();
if (result.type === 'success') editingItem = null;
};
uploadError = ''; uploadError = '';
const picture = formData.get('picture'); const picture = formData.get('picture');
formData.delete('picture'); formData.delete('picture');
if (!(picture instanceof File) || picture.size === 0) return; if (!(picture instanceof File) || picture.size === 0) return finish;
if (!['image/jpeg', 'image/png', 'image/webp'].includes(picture.type)) { if (!['image/jpeg', 'image/png', 'image/webp'].includes(picture.type)) {
uploadError = 'Choose a JPEG, PNG, or WebP image.'; uploadError = 'Choose a JPEG, PNG, or WebP image.';
cancel(); cancel();
@@ -44,9 +61,16 @@
} finally { } finally {
uploading = false; uploading = false;
} }
return finish;
}; };
</script> </script>
<svelte:window
onkeydown={(event) => {
if (event.key === 'Escape') editingItem = null;
}}
/>
<section class="mx-auto max-w-4xl space-y-6 px-4 py-10 sm:px-6 sm:py-14"> <section class="mx-auto max-w-4xl space-y-6 px-4 py-10 sm:px-6 sm:py-14">
<a <a
href={resolve('/lists')} href={resolve('/lists')}
@@ -54,6 +78,14 @@
><ArrowLeft size={16} aria-hidden="true" /> lists</a ><ArrowLeft size={16} aria-hidden="true" /> lists</a
> >
<div class="flex flex-wrap items-end justify-between gap-4"> <div class="flex flex-wrap items-end justify-between gap-4">
<div class="flex items-center gap-4">
{#if data.list.pictureUrl}<img
src={data.list.pictureUrl}
alt=""
width="96"
height="96"
class="h-20 w-20 rounded-lg bg-[var(--site-surface)] object-cover sm:h-24 sm:w-24"
/>{/if}
<div> <div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{data.list.name}</h1> <h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{data.list.name}</h1>
<p class="mt-2 text-base text-[var(--site-muted)]"> <p class="mt-2 text-base text-[var(--site-muted)]">
@@ -61,6 +93,7 @@
{data.total === 1 ? 'item' : 'items'} {data.total === 1 ? 'item' : 'items'}
</p> </p>
</div> </div>
</div>
{#if data.isAdmin}<button {#if data.isAdmin}<button
type="button" type="button"
onclick={() => (managing = !managing)} onclick={() => (managing = !managing)}
@@ -96,7 +129,8 @@
<form <form
method="POST" method="POST"
action="?/updateList" action="?/updateList"
use:enhance enctype="multipart/form-data"
use:enhance={uploadPicture}
class="grid gap-3 sm:grid-cols-2 sm:items-end" class="grid gap-3 sm:grid-cols-2 sm:items-end"
> >
<label <label
@@ -124,7 +158,15 @@
<option value="progress" selected={data.list.isProgress}>Progress</option> <option value="progress" selected={data.list.isProgress}>Progress</option>
</select></label </select></label
> >
<button class="rounded border p-2">Save list</button> <div class="sm:col-span-2">
<ListPictureDropzone label="List picture (optional)" />
{#if data.list.pictureUrl}<label class="mt-2 block text-sm"
><input type="checkbox" name="removePicture" /> Remove current picture</label
>{/if}
</div>
<button disabled={uploading} class="rounded border p-2 disabled:opacity-50"
>Save list</button
>
</form> </form>
<details class="border-t border-[var(--site-border)] pt-4"> <details class="border-t border-[var(--site-border)] pt-4">
<summary class="cursor-pointer font-medium text-red-700">Delete this list</summary> <summary class="cursor-pointer font-medium text-red-700">Delete this list</summary>
@@ -148,8 +190,17 @@
</div> </div>
</details> </details>
{/if} {/if}
<label class="block">
<span class="sr-only">Search list items</span>
<input
type="search"
bind:value={search}
placeholder="Search list items"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
/>
</label>
<ul class="divide-y divide-[var(--site-border)] border-t border-[var(--site-border)]"> <ul class="divide-y divide-[var(--site-border)] border-t border-[var(--site-border)]">
{#each data.list.items as item (item.id)} {#each visibleItems as item (item.id)}
<li class="py-4"> <li class="py-4">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
{#if item.pictureUrl}<img {#if item.pictureUrl}<img
@@ -180,29 +231,51 @@
</button> </button>
</form> </form>
{/if} {/if}
{#if data.isAdmin && managing}<button
type="button"
onclick={() => (editingItem = item)}
class="shrink-0 rounded border border-[var(--site-border)] px-3 py-1 text-sm hover:bg-[var(--site-surface)]"
aria-label={`Edit ${item.name}`}>Edit</button
>{/if}
</div>
</li>
{:else}<li class="py-5 text-[var(--site-muted)]">
{search ? 'No matching items' : 'no items yet'}
</li>{/each}
</ul>
{#if data.isAdmin && managing && editingItem}
<div class="fixed inset-0 z-40 bg-black/60"></div>
<div class="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-8">
<div
role="dialog"
aria-modal="true"
aria-label={`Edit ${editingItem.name}`}
class="mx-auto max-w-xl rounded-lg bg-[var(--site-bg)] p-5 shadow-xl"
>
<div class="mb-4 flex items-center justify-between gap-4">
<h2 class="text-xl font-semibold">Edit item</h2>
<button type="button" onclick={() => (editingItem = null)} class="underline">Close</button
>
</div> </div>
{#if data.isAdmin && managing}
<details class="mt-3 border-t border-[var(--site-border)] pt-3">
<summary class="cursor-pointer text-base underline">edit item</summary>
<form <form
method="POST" method="POST"
action="?/updateItem" action="?/updateItem"
enctype="multipart/form-data" enctype="multipart/form-data"
use:enhance={uploadPicture} use:enhance={uploadPicture}
class="mt-3 grid gap-3" class="grid gap-3"
> >
<input type="hidden" name="itemId" value={item.id} /> <input type="hidden" name="itemId" value={editingItem.id} />
<label <label
>Name<input >Name<input
name="name" name="name"
value={item.name} value={editingItem.name}
required required
maxlength="500" maxlength="500"
class="block w-full rounded border p-2" class="block w-full rounded border p-2"
/></label /></label
> >
<ListPictureDropzone label="Replace picture (optional)" /> <ListPictureDropzone label="Replace picture (optional)" />
{#if item.pictureUrl}<label class="text-sm" {#if editingItem.pictureUrl}<label class="text-sm"
><input type="checkbox" name="removePicture" /> Remove current picture</label ><input type="checkbox" name="removePicture" /> Remove current picture</label
>{/if} >{/if}
<label <label
@@ -210,7 +283,7 @@
name="description" name="description"
maxlength="5000" maxlength="5000"
rows="3" rows="3"
class="block w-full rounded border p-2">{item.description ?? ''}</textarea class="block w-full rounded border p-2">{editingItem.description ?? ''}</textarea
></label ></label
> >
<button <button
@@ -219,17 +292,24 @@
>Save item</button >Save item</button
> >
</form> </form>
<form method="POST" action="?/removeItem" use:enhance class="mt-3"> <form
<input type="hidden" name="itemId" value={item.id} /> method="POST"
<button class="text-sm text-red-700 underline" aria-label={`Remove ${item.name}`} action="?/removeItem"
use:enhance={() =>
async ({ result, update }) => {
await update();
if (result.type === 'success') editingItem = null;
}}
class="mt-5 border-t border-[var(--site-border)] pt-4"
>
<input type="hidden" name="itemId" value={editingItem.id} />
<button class="text-sm text-red-700 underline" aria-label={`Remove ${editingItem.name}`}
>Remove item</button >Remove item</button
> >
</form> </form>
</details> </div>
</div>
{/if} {/if}
</li>
{:else}<li class="py-5 text-[var(--site-muted)]">no items yet</li>{/each}
</ul>
{#if data.isAdmin && managing} {#if data.isAdmin && managing}
<details class="border-t border-[var(--site-border)] pt-5"> <details class="border-t border-[var(--site-border)] pt-5">
<summary class="cursor-pointer font-medium">add item</summary> <summary class="cursor-pointer font-medium">add item</summary>

View File

@@ -117,19 +117,24 @@ export const actions: Actions = {
const name = textField(form, 'name', 80); const name = textField(form, 'name', 80);
const openingBalanceCents = cents(form.get('openingBalance')); const openingBalanceCents = cents(form.get('openingBalance'));
const kind = String(form.get('kind') ?? 'spendable'); const kind = String(form.get('kind') ?? 'spendable');
const colorRaw = String(form.get('color') ?? '').trim();
const color = colorRaw ? colorRaw.toLowerCase() : null;
if (!name || openingBalanceCents == null || !['spendable', 'investment'].includes(kind)) if (!name || openingBalanceCents == null || !['spendable', 'investment'].includes(kind))
return invalid('Enter an account name and a valid opening balance.'); return invalid('Enter an account name and a valid opening balance.');
if (color && !/^#[0-9a-f]{6}$/.test(color)) return invalid('Choose a valid account color.');
const id = recordId(form); const id = recordId(form);
try { try {
if (id) { if (id) {
const [row] = await db const [row] = await db
.update(moneyAccounts) .update(moneyAccounts)
.set({ name, openingBalanceCents, kind }) .set({ name, openingBalanceCents, kind, color })
.where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, user.id))) .where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, user.id)))
.returning({ id: moneyAccounts.id }); .returning({ id: moneyAccounts.id });
if (!row) error(404, 'Account not found'); if (!row) error(404, 'Account not found');
} else } else
await db.insert(moneyAccounts).values({ userId: user.id, name, openingBalanceCents, kind }); await db
.insert(moneyAccounts)
.values({ userId: user.id, name, openingBalanceCents, kind, color });
} catch (cause) { } catch (cause) {
if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505') if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505')
return invalid('An account with that name already exists.'); return invalid('An account with that name already exists.');

View File

@@ -10,16 +10,16 @@
import MoneyChart from '$lib/money-tracker/MoneyChart.svelte'; import MoneyChart from '$lib/money-tracker/MoneyChart.svelte';
import { import {
accountBalances, accountBalances,
balanceSeries, accountColor,
monthlyCents, monthlyCents,
type TimelineView,
utilityEstimate, utilityEstimate,
wealthProjection wealthProjection,
wealthTimeline
} from '$lib/money-tracker/charts'; } from '$lib/money-tracker/charts';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
type Tab =
'overview' | 'transactions' | 'planning' | 'utilities' | 'wishlist' | 'insurance' | 'accounts';
type Mode = type Mode =
| 'account' | 'account'
| 'transaction' | 'transaction'
@@ -32,28 +32,27 @@
| 'insurance' | 'insurance'
| 'import' | 'import'
| 'delete'; | 'delete';
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'transactions', label: 'Transactions' },
{ id: 'planning', label: 'Planning' },
{ id: 'utilities', label: 'Utilities' },
{ id: 'wishlist', label: 'Wish list' },
{ id: 'insurance', label: 'Insurance' },
{ id: 'accounts', label: 'Accounts' }
];
const kinds = [ const kinds = [
{ id: 'electricity', label: 'Electricity', unit: 'kWh' }, { id: 'electricity', label: 'Electricity', unit: 'kWh' },
{ id: 'gas', label: 'Gas', unit: 'm³' }, { id: 'gas', label: 'Gas', unit: 'm³' },
{ id: 'water', label: 'Water', unit: 'm³' } { id: 'water', label: 'Water', unit: 'm³' }
] as const; ] as const;
const presetColors = [
'#000000',
'#ffd100',
'#009286',
'#0070f3',
'#e5484d',
'#8e4ec6',
'#e56910'
];
const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' }); const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
let tab = $state<Tab>('overview');
let mode = $state<Mode>('transaction'); let mode = $state<Mode>('transaction');
let active = $state<Record<string, string | number | null>>({}); let active = $state<Record<string, string | number | null>>({});
let deleteAction = $state(''); let deleteAction = $state('');
let showActionError = $state(false); let showActionError = $state(false);
let search = $state(''); let search = $state('');
let accountFilter = $state('all'); let accountFilter = $state<number[]>([]);
let sortOrder = $state<'newest' | 'oldest'>('newest'); let sortOrder = $state<'newest' | 'oldest'>('newest');
let pageNumber = $state(1); let pageNumber = $state(1);
let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity'); let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity');
@@ -62,12 +61,29 @@
let futureTransactions = $derived(data.transactions.filter((item) => item.date > data.today)); let futureTransactions = $derived(data.transactions.filter((item) => item.date > data.today));
let balances = $derived(accountBalances(data.accounts, actualTransactions)); let balances = $derived(accountBalances(data.accounts, actualTransactions));
let total = $derived([...balances.values()].reduce((sum, value) => sum + value, 0)); let total = $derived([...balances.values()].reduce((sum, value) => sum + value, 0));
let series = $derived(balanceSeries(data.accounts, actualTransactions)); let invested = $derived(
let recent = $derived([...actualTransactions].reverse().slice(0, 8)); data.accounts
.filter((account) => account.kind === 'investment')
.reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
);
let colors = $derived(
new Map(data.accounts.map((account, index) => [account.id, accountColor(account, index)]))
);
let monthNet = $derived(
actualTransactions
.filter((item) => item.date.slice(0, 7) === data.today.slice(0, 7))
.reduce((sum, item) => sum + item.amountCents, 0)
);
let monthlyInsurance = $derived(
data.insurances.reduce(
(sum, item) => sum + monthlyCents(item.premiumCents ?? 0, item.cadence),
0
)
);
let filtered = $derived( let filtered = $derived(
(sortOrder === 'newest' ? [...data.transactions].reverse() : data.transactions).filter( (sortOrder === 'newest' ? [...data.transactions].reverse() : data.transactions).filter(
(row) => (row) =>
(accountFilter === 'all' || String(row.accountId) === accountFilter) && (!accountFilter.length || accountFilter.includes(row.accountId)) &&
(!search || (!search ||
row.description.toLowerCase().includes(search.toLowerCase()) || row.description.toLowerCase().includes(search.toLowerCase()) ||
accountName(row.accountId).toLowerCase().includes(search.toLowerCase())) accountName(row.accountId).toLowerCase().includes(search.toLowerCase()))
@@ -75,7 +91,13 @@
); );
let shown = $derived(filtered.slice((pageNumber - 1) * 25, pageNumber * 25)); let shown = $derived(filtered.slice((pageNumber - 1) * 25, pageNumber * 25));
let monthlyRecurring = $derived( let monthlyRecurring = $derived(
data.recurring.reduce((sum, item) => sum + monthlyCents(item.amountCents, item.cadence), 0) data.recurring
.filter(
(item) =>
(!item.startsOn || item.startsOn <= data.today) &&
(!item.endsOn || item.endsOn >= data.today)
)
.reduce((sum, item) => sum + monthlyCents(item.amountCents, item.cadence), 0)
); );
let projection = $derived( let projection = $derived(
wealthProjection( wealthProjection(
@@ -88,6 +110,16 @@
data.today data.today
) )
); );
const timelineViews: { id: TimelineView; label: string }[] = [
{ id: 'total', label: 'Total' },
{ id: 'split', label: 'Spendable vs invested' },
{ id: 'accounts', label: 'By account' }
];
let timelineView = $state<TimelineView>('total');
let timeline = $derived(
wealthTimeline(data.accounts, actualTransactions, projection, data.today, timelineView)
);
let projectionEnd = $derived(projection.map((line) => line.points.at(-1)?.value ?? 0));
let wishTiers = $derived( let wishTiers = $derived(
[5, 4, 3, 2, 1].map((tier) => ({ [5, 4, 3, 2, 1].map((tier) => ({
tier, tier,
@@ -144,20 +176,11 @@
showActionError = false; showActionError = false;
dialog.showModal(); dialog.showModal();
} }
function createForTab() { function toggleAccount(id: number) {
open( accountFilter = accountFilter.includes(id)
tab === 'accounts' ? accountFilter.filter((item) => item !== id)
? 'account' : [...accountFilter, id];
: tab === 'transactions' || tab === 'overview' pageNumber = 1;
? 'transaction'
: tab === 'planning'
? 'recurring'
: tab === 'utilities'
? 'reading'
: tab === 'wishlist'
? 'wish'
: 'insurance'
);
} }
function deletion(name: string, id: number, action: string) { function deletion(name: string, id: number, action: string) {
open('delete', { id, name }, action); open('delete', { id, name }, action);
@@ -188,76 +211,71 @@
} }
</script> </script>
<div class="money-app mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12"> {#snippet signed(value: number)}
<span class:positive={value > 0} class:negative={value < 0}>{amount(value)}</span>
{/snippet}
{#snippet swatch(id: number | null)}
<span class="swatch" style:background={id === null ? 'transparent' : colors.get(id)}></span>
{/snippet}
{#snippet rowActions(label: string, edit: () => void, remove: () => void)}
<span class="actions"
><button aria-label={'Edit ' + label} onclick={edit}><Pencil size={14} /></button><button
aria-label={'Delete ' + label}
onclick={remove}><Trash2 size={14} /></button
></span
>
{/snippet}
<div class="money-app mx-auto max-w-screen-2xl px-4 py-8 sm:px-6 sm:py-10">
<header class="app-header"> <header class="app-header">
<div> <div>
<p class="muted">Personal finance</p> <p class="muted">Personal finance</p>
<h1>Money tracker</h1> <h1>Money tracker</h1>
<p class="muted">One place for every account, plan and household cost.</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="button secondary" onclick={() => open('import')} <button class="button secondary" onclick={() => open('import')}
><Upload size={16} /> Import CSV</button ><Upload size={16} /> Import CSV</button
><button class="button primary" onclick={createForTab} ><button class="button primary" onclick={() => open('transaction')}
><Plus size={16} /> Add {tab === 'overview' || tab === 'transactions' ><Plus size={16} /> Add transaction</button
? 'transaction'
: tab === 'planning'
? 'recurring item'
: tab === 'utilities'
? 'reading'
: tab === 'wishlist'
? 'wish'
: tab === 'insurance'
? 'policy'
: 'account'}</button
> >
</div> </div>
</header> </header>
<nav class="tabs" aria-label="Money tracker sections">
{#each tabs as item (item.id)}<button
class:current={tab === item.id}
aria-current={tab === item.id ? 'page' : undefined}
onclick={() => {
tab = item.id;
pageNumber = 1;
}}>{item.label}</button
>{/each}
</nav>
{#if tab === 'overview'} <section class="stat-strip" aria-label="Summary">
<section class="overview-grid" aria-label="Account overview"> <div class="stat net">
<div class="net-worth"> <p class="eyebrow">Net worth · {data.accounts.length} accounts</p>
<p class="eyebrow">Across {data.accounts.length} accounts</p> <p class="stat-value">{@render signed(total)}</p>
<p class="net-value">{amount(total)}</p>
<p class="muted">Current combined balance through {dateLabel(data.today)}</p>
</div> </div>
<div class="overview-side"> <div class="stat">
<div> <p class="eyebrow">Spendable</p>
<p class="eyebrow">Transactions</p> <p class="stat-value">{@render signed(total - invested)}</p>
<p class="side-value">{data.transactions.length.toLocaleString('en-NL')}</p>
</div> </div>
<div> <div class="stat">
<p class="eyebrow">Monthly recurring net</p> <p class="eyebrow">Invested</p>
<p class="side-value">{amount(Math.round(monthlyRecurring))}</p> <p class="stat-value">{@render signed(invested)}</p>
</div> </div>
<div class="stat">
<p class="eyebrow">Net this month</p>
<p class="stat-value">{@render signed(monthNet)}</p>
</div> </div>
</section> <div class="stat">
<section class="panel"> <p class="eyebrow">Recurring / month</p>
<div class="panel-heading"> <p class="stat-value">{@render signed(Math.round(monthlyRecurring))}</p>
<div> </div>
<h2>Account history</h2> <div class="stat">
<p> <p class="eyebrow">
End-of-day balances through today. Future dated transactions appear in the projection. Projected {dateLabel(projection[0].points.at(-1)?.date ?? data.today)}
</p> </p>
<p class="stat-value">{@render signed(projectionEnd[0])}</p>
</div> </div>
</div>
<MoneyChart {series} title="Individual and combined account balances over time" />
</section> </section>
<section class="panel">
<div class="dashboard">
<section class="panel span-8">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h2>Expected wealth</h2> <h2>Wealth over time</h2>
<p>From today through {dateLabel(projection[0].points.at(-1)?.date ?? data.today)}.</p> <p>Actual balances through today, then the projection.</p>
</div> </div>
<button <button
class="button secondary" class="button secondary"
@@ -265,71 +283,85 @@
>Edit projection</button >Edit projection</button
> >
</div> </div>
<MoneyChart series={projection} title="Expected total, spendable and invested wealth" /> <div class="subtabs" role="group" aria-label="Chart view">
{#each timelineViews as item (item.id)}<button
type="button"
class:current={timelineView === item.id}
aria-pressed={timelineView === item.id}
onclick={() => (timelineView = item.id)}>{item.label}</button
>{/each}
</div>
<MoneyChart
series={timeline}
title="Actual and projected wealth over time"
marker={{ date: data.today, label: 'Today' }}
/>
<div class="projection-totals" aria-label="Projected balances at end of period">
<div>
<span class="eyebrow">Total at end</span><strong
>{@render signed(projectionEnd[0])}</strong
>
</div>
<div>
<span class="eyebrow">Spendable at end</span><strong
>{@render signed(projectionEnd[1])}</strong
>
</div>
<div>
<span class="eyebrow">Invested at end</span><strong
>{@render signed(projectionEnd[2])}</strong
>
</div>
</div>
<p class="muted projection-note"> <p class="muted projection-note">
Includes future dated transactions and estimates{data.projectionSettings Includes future dated transactions and estimates{data.projectionSettings
?.includeRecurring !== 0 ?.includeRecurring !== 0
? ', plus recurring monthly equivalents from next month' ? ', plus recurring monthly equivalents from next month'
: ''}. {data.projectionSettings?.investmentAccountId : ''}. {data.projectionSettings?.investmentAccountId
? `The selected investment account has a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return; contributions move money between accounts.` ? `The selected investment account and new contributions use a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return.`
: 'Choose an investment account to model contributions and growth.'} Enter an item only once : `Excess above ${amount(data.projectionSettings?.reserveCents ?? 100000)} moves into a projected investment balance with a hypothetical ${data.projectionSettings?.annualReturnPercent ?? '5.00'}% annual return.`}
across transactions, estimates, and recurring items to avoid counting it twice. Contributions move money between spendable and invested balances. Enter an item only once across
transactions, estimates, and recurring items to avoid counting it twice.
</p> </p>
</section> </section>
<section class="panel">
<section class="panel span-4" id="accounts">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h2>Wish list total</h2>
<p>Cumulative estimated cost by priority, from tier 5 down.</p>
</div>
<button class="text-button" onclick={() => (tab = 'wishlist')}>View wish list</button>
</div>
<div class="wish-tier-grid">
{#each wishTiers as row (row.tier)}<div>
<span class="eyebrow">{row.tier === 5 ? 'Tier 5' : `Tiers 5–${row.tier}`}</span><strong
>{amount(row.value)}</strong
><small>{row.count} {row.count === 1 ? 'item' : 'items'}</small>
</div>{/each}
</div>
{#if unpricedWishes}<p class="muted projection-note">
{unpricedWishes} unpriced {unpricedWishes === 1 ? 'item is' : 'items are'} excluded from these
totals.
</p>{/if}
</section>
<div class="two-columns">
<section class="panel">
<div class="panel-heading">
<h2>Accounts</h2> <h2>Accounts</h2>
<button class="text-button" onclick={() => (tab = 'accounts')}>View all</button> <p>Select accounts to filter transactions.</p>
</div> </div>
{#if data.accounts.length}<ul class="summary-list"> <button class="button secondary" onclick={() => open('account')}
{#each data.accounts as account (account.id)}<li> ><Plus size={16} /> Add</button
<span>{account.name}</span><strong>{amount(balances.get(account.id) ?? 0)}</strong> >
</div>
{#if data.accounts.length}<ul class="item-list">
{#each data.accounts as account (account.id)}<li
class:selected={accountFilter.includes(account.id)}
>
<button
type="button"
class="item-main"
aria-pressed={accountFilter.includes(account.id)}
onclick={() => toggleAccount(account.id)}
>{@render swatch(account.id)}<span
><strong>{account.name}</strong><small class="capitalize"
>{account.kind} · opening {amount(account.openingBalanceCents)}</small
></span
></button
><strong class="item-amount">{@render signed(balances.get(account.id) ?? 0)}</strong
>{@render rowActions(
account.name,
() => open('account', account),
() => deletion(account.name, account.id, 'deleteAccount')
)}
</li>{/each} </li>{/each}
</ul>{:else}<p class="empty"> </ul>{:else}<p class="empty">
Add an account or import your transactions to get started. Add an account or import your transactions to get started.
</p>{/if} </p>{/if}
</section> </section>
<section class="panel">
<div class="panel-heading"> <section class="panel span-8" id="transactions">
<h2>Recent transactions</h2>
<button class="text-button" onclick={() => (tab = 'transactions')}>View all</button>
</div>
{#if recent.length}<ul class="summary-list">
{#each recent as row (row.id)}<li>
<span
><strong>{row.description}</strong><small
>{accountName(row.accountId)} · {dateLabel(row.date)}</small
></span
><strong class:negative={row.amountCents < 0}>{amount(row.amountCents)}</strong>
</li>{/each}
</ul>{:else}<p class="empty">
Transactions will appear here after you add or import them.
</p>{/if}
</section>
</div>
{:else if tab === 'transactions'}
<section class="panel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h2>Transactions</h2> <h2>Transactions</h2>
@@ -346,15 +378,7 @@
placeholder="Search descriptions" placeholder="Search descriptions"
bind:value={search} bind:value={search}
oninput={() => (pageNumber = 1)} oninput={() => (pageNumber = 1)}
/><label class="sr-only" for="account-filter">Filter account</label><select />
id="account-filter"
bind:value={accountFilter}
onchange={() => (pageNumber = 1)}
><option value="all">All accounts</option
>{#each data.accounts as account (account.id)}<option value={account.id}
>{account.name}</option
>{/each}</select
>
<label class="sr-only" for="transaction-order">Transaction order</label><select <label class="sr-only" for="transaction-order">Transaction order</label><select
id="transaction-order" id="transaction-order"
bind:value={sortOrder} bind:value={sortOrder}
@@ -363,6 +387,23 @@
></select ></select
> >
</div> </div>
{#if data.accounts.length}<div class="chips" role="group" aria-label="Filter by account">
<button
type="button"
class:current={!accountFilter.length}
aria-pressed={!accountFilter.length}
onclick={() => {
accountFilter = [];
pageNumber = 1;
}}>All accounts</button
>{#each data.accounts as account (account.id)}<button
type="button"
class:current={accountFilter.includes(account.id)}
aria-pressed={accountFilter.includes(account.id)}
onclick={() => toggleAccount(account.id)}
>{@render swatch(account.id)}{account.name}</button
>{/each}
</div>{/if}
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead <thead
@@ -372,19 +413,21 @@
></tr ></tr
></thead ></thead
><tbody ><tbody
>{#each shown as row (row.id)}<tr >{#each shown as row (row.id)}<tr class:future={row.date > data.today}
><td>{dateLabel(row.date)}</td><td class="strong">{row.description}</td><td ><td class="nowrap">{dateLabel(row.date)}</td><td class="strong"
>{accountName(row.accountId)}</td >{row.description}</td
><td class="numeric" class:negative={row.amountCents < 0} ><td class="nowrap"
>{amount(row.amountCents)}</td ><span class="account-cell"
><td class="actions" >{@render swatch(row.accountId)}{accountName(row.accountId)}</span
></td
><td class="numeric">{@render signed(row.amountCents)}</td><td class="actions"
><button ><button
aria-label={'Edit ' + row.description} aria-label={'Edit ' + row.description}
onclick={() => open('transaction', row)}><Pencil size={15} /></button onclick={() => open('transaction', row)}><Pencil size={14} /></button
><button ><button
aria-label={'Delete ' + row.description} aria-label={'Delete ' + row.description}
onclick={() => deletion(row.description, row.id, 'deleteTransaction')} onclick={() => deletion(row.description, row.id, 'deleteTransaction')}
><Trash2 size={15} /></button ><Trash2 size={14} /></button
></td ></td
></tr ></tr
>{/each}</tbody >{/each}</tbody
@@ -408,110 +451,82 @@
</div> </div>
</div>{/if} </div>{/if}
</section> </section>
{:else if tab === 'planning'}
<div class="planning-intro"> <div class="span-4 stack">
<div> <section class="panel" id="recurring">
<p class="eyebrow">Monthly equivalent</p>
<p class="large-value">{amount(Math.round(monthlyRecurring))}</p>
<p class="muted">Signed total of recurring income and costs</p>
</div>
<p class="muted">
Recurring items feed the Overview projection when enabled. Dated forecasts do not change
actual balances.
</p>
</div>
<section class="panel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h2>Recurring income and costs</h2> <h2>Recurring</h2>
<p>Rent, subscriptions, salary and other known repeats.</p> <p>
{@render signed(Math.round(monthlyRecurring))} per month now. Feeds the projection when
enabled.
</p>
</div> </div>
<button class="button secondary" onclick={() => open('recurring')} <button class="button secondary" onclick={() => open('recurring')}
><Plus size={16} /> Add item</button ><Plus size={16} /> Add</button
> >
</div> </div>
<div class="table-wrap"> {#if data.recurring.length}<ul class="item-list scroll">
<table> {#each data.recurring as row (row.id)}<li>
<thead <span class="item-main"
><tr >{@render swatch(row.accountId)}<span
><th>Name</th><th>Account</th><th>Frequency</th><th>Dates</th><th class="numeric" ><strong>{row.name}</strong><small
>Amount</th ><span class="capitalize">{row.cadence}</span> · {row.accountId
><th class="actions-heading">Actions</th></tr ? accountName(row.accountId)
></thead : 'Any account'} · {row.startsOn ? dateLabel(row.startsOn) : 'Now'} – {row.endsOn
><tbody
>{#each data.recurring as row (row.id)}<tr
><td class="strong">{row.name}</td><td
>{row.accountId ? accountName(row.accountId) : 'Any'}</td
><td class="capitalize">{row.cadence}</td><td
>{row.startsOn ? dateLabel(row.startsOn) : 'Now'} – {row.endsOn
? dateLabel(row.endsOn) ? dateLabel(row.endsOn)
: 'ongoing'}</td : 'ongoing'}</small
><td class="numeric" class:negative={row.amountCents < 0} ></span
>{amount(row.amountCents)}</td ></span
><td class="actions" ><strong class="item-amount">{@render signed(row.amountCents)}</strong
><button aria-label={'Edit ' + row.name} onclick={() => open('recurring', row)} >{@render rowActions(
><Pencil size={15} /></button row.name,
><button () => open('recurring', row),
aria-label={'Delete ' + row.name} () => deletion(row.name, row.id, 'deleteRecurring')
onclick={() => deletion(row.name, row.id, 'deleteRecurring')} )}
><Trash2 size={15} /></button </li>{/each}
></td </ul>{:else}<p class="empty">Rent, subscriptions, salary and other known repeats.</p>{/if}
></tr
>{/each}</tbody
>
</table>
</div>
{#if !data.recurring.length}<p class="empty">No recurring items yet.</p>{/if}
</section> </section>
<section class="panel"> <section class="panel" id="estimates">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h2>Future estimates</h2> <h2>Future estimates</h2>
<p>Dated income and costs you expect.</p> <p>Dated income and costs you expect.</p>
</div> </div>
<button class="button secondary" onclick={() => open('forecast')} <button class="button secondary" onclick={() => open('forecast')}
><Plus size={16} /> Add estimate</button ><Plus size={16} /> Add</button
> >
</div> </div>
<div class="table-wrap"> {#if data.forecasts.length}<ul class="item-list scroll">
<table> {#each data.forecasts as row (row.id)}<li>
<thead <span class="item-main"
><tr >{@render swatch(row.accountId)}<span
><th>Date</th><th>Description</th><th>Account</th><th class="numeric">Amount</th><th ><strong>{row.description}</strong><small
class="actions-heading">Actions</th >{dateLabel(row.date)} · {row.accountId
></tr ? accountName(row.accountId)
></thead : 'Unassigned'}</small
><tbody ></span
>{#each data.forecasts as row (row.id)}<tr ></span
><td>{dateLabel(row.date)}</td><td class="strong">{row.description}</td><td ><strong class="item-amount">{@render signed(row.amountCents)}</strong
>{row.accountId ? accountName(row.accountId) : 'Unassigned'}</td >{@render rowActions(
><td class="numeric" class:negative={row.amountCents < 0} row.description,
>{amount(row.amountCents)}</td () => open('forecast', row),
><td class="actions" () => deletion(row.description, row.id, 'deleteForecast')
><button )}
aria-label={'Edit ' + row.description} </li>{/each}
onclick={() => open('forecast', row)}><Pencil size={15} /></button </ul>{:else}<p class="empty">Add expected costs or income to look ahead.</p>{/if}
><button
aria-label={'Delete ' + row.description}
onclick={() => deletion(row.description, row.id, 'deleteForecast')}
><Trash2 size={15} /></button
></td
></tr
>{/each}</tbody
>
</table>
</div>
{#if !data.forecasts.length}<p class="empty">
Add expected costs or income to look ahead.
</p>{/if}
</section> </section>
{:else if tab === 'utilities'} </div>
<section class="panel">
<section class="panel span-4" id="utilities">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h2>Utilities</h2> <h2>Utilities</h2>
<p>Meter readings, estimated variable cost and monthly advances.</p> <p>Meter readings and estimated variable cost.</p>
</div> </div>
<button class="button secondary" onclick={() => open('reading', { kind: selectedUtility })}
><Plus size={16} /> Reading</button
>
</div> </div>
<div class="subtabs"> <div class="subtabs">
{#each kinds as kind (kind.id)}<button {#each kinds as kind (kind.id)}<button
@@ -519,36 +534,31 @@
onclick={() => (selectedUtility = kind.id)}>{kind.label}</button onclick={() => (selectedUtility = kind.id)}>{kind.label}</button
>{/each} >{/each}
</div> </div>
<div class="utility-top"> <div class="mini-stats">
<div> <div>
<p class="eyebrow">Usage in billing period</p> <span class="eyebrow">Usage</span><strong
<p class="large-value"> >{estimate ? estimate.usage.toLocaleString('en-NL', { maximumFractionDigits: 3 }) : '—'}
{estimate ? estimate.usage.toLocaleString('en-NL', { maximumFractionDigits: 3 }) : '—'} <small>{utility.unit}</small></strong
<span>{utility.unit}</span> >
</p>
</div> </div>
<div> <div>
<p class="eyebrow">Estimated variable cost</p> <span class="eyebrow">Est. cost</span><strong
<p class="large-value">{estimate ? amount(estimate.estimatedCents) : '—'}</p> >{estimate ? amount(estimate.estimatedCents) : '—'}</strong
>
</div> </div>
<div> <div>
<p class="eyebrow">Advances less estimated cost</p> <span class="eyebrow">Advances − cost</span><strong
<p class="large-value">{estimate ? amount(estimate.differenceCents) : '—'}</p> >{#if estimate}{@render signed(estimate.differenceCents)}{:else}—{/if}</strong
>
</div> </div>
</div> </div>
<p class="muted utility-note">
Estimate uses the first and last readings in the billing period, the current unit rate and
prorated monthly advances. Fixed charges, taxes, rate changes and supplier settlements are
not included.
</p>
<MoneyChart <MoneyChart
series={utilitySeries} series={utilitySeries}
title={utility.label + ' meter readings over time'} title={utility.label + ' meter readings over time'}
unit="" unit=""
/> />
<div class="utility-actions">
<button <button
class="button secondary" class="text-button utility-settings"
onclick={() => onclick={() =>
open('settings', { open('settings', {
kind: selectedUtility, kind: selectedUtility,
@@ -556,164 +566,99 @@
monthlyAdvanceCents: settings?.monthlyAdvanceCents ?? 0, monthlyAdvanceCents: settings?.monthlyAdvanceCents ?? 0,
billingStart: settings?.billingStart ?? '' billingStart: settings?.billingStart ?? ''
})}>Edit rate and advance</button })}>Edit rate and advance</button
><button class="button primary" onclick={() => open('reading', { kind: selectedUtility })}
><Plus size={16} /> Add reading</button
> >
</div> {#if readings.length}<ul class="item-list scroll">
</section> {#each [...readings].reverse() as row (row.id)}<li>
<section class="panel"> <span class="item-main"><span><strong>{dateLabel(row.date)}</strong></span></span
<div class="panel-heading"> ><span class="item-amount"
<h2>{utility.label} readings</h2> >{Number(row.reading).toLocaleString('en-NL', { maximumFractionDigits: 3 })}
<p>{readings.length} recorded</p> {utility.unit}</span
</div> >{@render rowActions(
<div class="table-wrap"> 'reading from ' + row.date,
<table> () => open('reading', row),
<thead () =>
><tr
><th>Date</th><th class="numeric">Meter value ({utility.unit})</th><th
class="actions-heading">Actions</th
></tr
></thead
><tbody
>{#each [...readings].reverse() as row (row.id)}<tr
><td>{dateLabel(row.date)}</td><td class="numeric strong"
>{Number(row.reading).toLocaleString('en-NL', { maximumFractionDigits: 3 })}</td
><td class="actions"
><button
aria-label={'Edit reading from ' + row.date}
onclick={() => open('reading', row)}><Pencil size={15} /></button
><button
aria-label={'Delete reading from ' + row.date}
onclick={() =>
deletion( deletion(
utility.label + ' reading on ' + dateLabel(row.date), utility.label + ' reading on ' + dateLabel(row.date),
row.id, row.id,
'deleteReading' 'deleteReading'
)}><Trash2 size={15} /></button )
></td )}
></tr </li>{/each}
>{/each}</tbody </ul>{:else}<p class="empty">Add at least two readings to calculate usage.</p>{/if}
> <p class="muted utility-note">
</table> Uses the first and last readings in the billing period, the current unit rate and prorated
</div> monthly advances. Fixed charges, taxes and settlements are not included.
{#if !readings.length}<p class="empty">Add at least two readings to calculate usage.</p>{/if} </p>
</section> </section>
{:else if tab === 'wishlist'}
<section class="panel"> <section class="panel span-4" id="wishlist">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h2>Wish list</h2> <h2>Wish list</h2>
<p>Ideas for later, kept outside your forecasts.</p> <p>Cumulative cost by priority tier.</p>
</div> </div>
</div> <button class="button secondary" onclick={() => open('wish')}><Plus size={16} /> Add</button
<div class="table-wrap">
<table>
<thead
><tr
><th>Item</th><th>Tier</th><th>Notes</th><th class="numeric">Estimated cost</th><th
class="actions-heading">Actions</th
></tr
></thead
><tbody
>{#each data.wishlist as row (row.id)}<tr
><td class="strong">{row.name}</td><td>{row.priority}</td><td>{row.notes ?? '—'}</td
><td class="numeric"
>{row.estimatedCents == null ? '—' : amount(row.estimatedCents)}</td
><td class="actions"
><button aria-label={'Edit ' + row.name} onclick={() => open('wish', row)}
><Pencil size={15} /></button
><button
aria-label={'Delete ' + row.name}
onclick={() => deletion(row.name, row.id, 'deleteWishlist')}
><Trash2 size={15} /></button
></td
></tr
>{/each}</tbody
> >
</table>
</div> </div>
{#if !data.wishlist.length}<p class="empty">Nothing on your wish list yet.</p>{/if} <div class="wish-tier-grid">
{#each wishTiers as row (row.tier)}<div>
<span class="eyebrow">{row.tier === 5 ? 'Tier 5' : `5–${row.tier}`}</span><strong
>{amount(row.value)}</strong
><small>{row.count} {row.count === 1 ? 'item' : 'items'}</small>
</div>{/each}
</div>
{#if unpricedWishes}<p class="muted projection-note">
{unpricedWishes} unpriced {unpricedWishes === 1 ? 'item is' : 'items are'} excluded from these
totals.
</p>{/if}
{#if data.wishlist.length}<ul class="item-list scroll">
{#each data.wishlist as row (row.id)}<li>
<span class="item-main"
><span class="tier">{row.priority}</span><span
><strong>{row.name}</strong>{#if row.notes}<small>{row.notes}</small>{/if}</span
></span
><strong class="item-amount"
>{row.estimatedCents == null ? '—' : amount(row.estimatedCents)}</strong
>{@render rowActions(
row.name,
() => open('wish', row),
() => deletion(row.name, row.id, 'deleteWishlist')
)}
</li>{/each}
</ul>{:else}<p class="empty">Nothing on your wish list yet.</p>{/if}
</section> </section>
{:else if tab === 'insurance'}
<section class="panel"> <section class="panel span-4" id="insurance">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h2>Insurance</h2> <h2>Insurance</h2>
<p>Policies, providers, coverage and premiums in one place.</p> <p>{amount(Math.round(monthlyInsurance))} per month in premiums.</p>
</div> </div>
<button class="button secondary" onclick={() => open('insurance')}
><Plus size={16} /> Add</button
>
</div> </div>
<div class="table-wrap"> {#if data.insurances.length}<ul class="item-list scroll">
<table> {#each data.insurances as row (row.id)}<li>
<thead <span class="item-main"
><tr ><span
><th>Policy</th><th>Provider</th><th>Coverage</th><th class="numeric">Premium</th><th ><strong>{row.name}</strong><small
class="actions-heading">Actions</th >{[row.provider, row.coverage].filter(Boolean).join(' · ') || '—'}</small
></tr ></span
></thead ></span
><tbody ><strong class="item-amount"
>{#each data.insurances as row (row.id)}<tr
><td class="strong">{row.name}</td><td>{row.provider ?? '—'}</td><td
>{row.coverage ?? '—'}</td
><td class="numeric"
>{row.premiumCents == null >{row.premiumCents == null
? '—' ? '—'
: amount(row.premiumCents) + ' / ' + row.cadence}</td : amount(row.premiumCents) + ' / ' + row.cadence}</strong
><td class="actions" >{@render rowActions(
><button aria-label={'Edit ' + row.name} onclick={() => open('insurance', row)} row.name,
><Pencil size={15} /></button () => open('insurance', row),
><button () => deletion(row.name, row.id, 'deleteInsurance')
aria-label={'Delete ' + row.name} )}
onclick={() => deletion(row.name, row.id, 'deleteInsurance')} </li>{/each}
><Trash2 size={15} /></button </ul>{:else}<p class="empty">Add policies to keep track of what is covered.</p>{/if}
></td
></tr
>{/each}</tbody
>
</table>
</div>
{#if !data.insurances.length}<p class="empty">
Add policies to keep track of what is covered.
</p>{/if}
</section> </section>
{:else if tab === 'accounts'}
<section class="panel">
<div class="panel-heading">
<div>
<h2>Accounts</h2>
<p>Opening balances plus every signed transaction.</p>
</div> </div>
</div>
<div class="table-wrap">
<table>
<thead
><tr
><th>Account</th><th>Type</th><th class="numeric">Opening balance</th><th
class="numeric">Current balance</th
><th class="actions-heading">Actions</th></tr
></thead
><tbody
>{#each data.accounts as row (row.id)}<tr
><td class="strong">{row.name}</td><td class="capitalize">{row.kind}</td><td
class="numeric">{amount(row.openingBalanceCents)}</td
><td class="numeric strong">{amount(balances.get(row.id) ?? 0)}</td><td
class="actions"
><button aria-label={'Edit ' + row.name} onclick={() => open('account', row)}
><Pencil size={15} /></button
><button
aria-label={'Delete ' + row.name}
onclick={() => deletion(row.name, row.id, 'deleteAccount')}
><Trash2 size={15} /></button
></td
></tr
>{/each}</tbody
>
</table>
</div>
{#if !data.accounts.length}<p class="empty">
Add your first account or import a CSV to create them automatically.
</p>{/if}
</section>
{/if}
</div> </div>
<dialog bind:this={dialog} class="money-dialog" onclose={() => (active = {})}> <dialog bind:this={dialog} class="money-dialog" onclose={() => (active = {})}>
@@ -842,6 +787,20 @@
>Investment accounts are included in total wealth but excluded from the cash reserve.</small >Investment accounts are included in total wealth but excluded from the cash reserve.</small
></label ></label
> >
<label class="field"
>Color<input
name="color"
type="color"
list="account-colors"
value={active.color ??
(typeof active.id === 'number'
? colors.get(active.id)
: accountColor({}, data.accounts.length))}
/><datalist id="account-colors"
>{#each presetColors as color (color)}<option value={color}></option>{/each}</datalist
><small>Used for the account's chart line, filter chip and transaction rows.</small
></label
>
{:else if mode === 'transaction' || mode === 'forecast'} {:else if mode === 'transaction' || mode === 'forecast'}
<label class="field" <label class="field"
>Date<input >Date<input
@@ -927,16 +886,19 @@
{:else if mode === 'projectionSettings'} {:else if mode === 'projectionSettings'}
<p class="dialog-copy"> <p class="dialog-copy">
A planning scenario. Returns are hypothetical and calculated monthly. Contributions A planning scenario. Returns are hypothetical and calculated monthly. Contributions
transfer money from spendable accounts into the selected investment account. transfer money from spendable accounts into a projected investment balance.
</p> </p>
<label class="field" <label class="field"
>Investment account<select name="investmentAccountId" >Existing investment account (optional)<select name="investmentAccountId"
><option value="">No investment model</option ><option value="">Start projected investments at €0</option
>{#each data.accounts.filter((account) => account.kind === 'investment') as account (account.id)}<option >{#each data.accounts.filter((account) => account.kind === 'investment') as account (account.id)}<option
value={account.id} value={account.id}
selected={active.investmentAccountId === account.id}>{account.name}</option selected={active.investmentAccountId === account.id}>{account.name}</option
>{/each}</select >{/each}</select
><small>Mark an account as Investment in Accounts to select it here.</small></label ><small
>Select an existing investment account to include its current balance in the growth
model.</small
></label
> >
<label class="field" <label class="field"
>Spendable reserve (€)<input >Spendable reserve (€)<input

View File

@@ -40,7 +40,6 @@
} }
.money-app .button.secondary:hover, .money-app .button.secondary:hover,
.money-dialog .button.secondary:hover, .money-dialog .button.secondary:hover,
.money-app .tabs button:hover,
.money-app .subtabs button:hover { .money-app .subtabs button:hover {
background: var(--site-hover); background: var(--site-hover);
} }
@@ -55,8 +54,7 @@
align-items: end; align-items: end;
justify-content: space-between; justify-content: space-between;
gap: 24px; gap: 24px;
border-bottom: 1px solid var(--site-border); margin-bottom: 20px;
padding-bottom: 30px;
} }
.money-app h1 { .money-app h1 {
margin: 2px 0 8px; margin: 2px 0 8px;
@@ -70,84 +68,43 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.money-app .tabs {
display: flex;
gap: 2px;
overflow-x: auto;
border-bottom: 1px solid var(--site-border);
margin-bottom: 32px;
}
.money-app .tabs button {
border-bottom: 2px solid transparent;
color: var(--site-muted);
white-space: nowrap;
padding: 14px 16px 12px;
font-size: 13px;
}
.money-app .tabs button.current {
color: var(--site-text);
border-color: var(--site-text);
font-weight: 600;
}
.money-app .overview-grid {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(250px, 0.8fr);
border: 1px solid var(--site-border);
border-radius: 10px;
overflow: hidden;
margin-bottom: 28px;
}
.money-app .net-worth {
padding: 32px;
min-width: 0;
}
.money-app .net-value {
font-size: clamp(2.5rem, 6vw, 4rem);
line-height: 1.2;
font-weight: 600;
letter-spacing: -0.055em;
overflow-wrap: anywhere;
margin: 8px 0;
}
.money-app .overview-side {
border-left: 1px solid var(--site-border);
display: grid;
grid-template-rows: 1fr 1fr;
}
.money-app .overview-side > div {
padding: 22px 26px;
}
.money-app .overview-side > div + div {
border-top: 1px solid var(--site-border);
}
.money-app .side-value {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.04em;
margin-top: 7px;
}
.money-app .projection-note { .money-app .projection-note {
margin-top: 14px; margin-top: 14px;
line-height: 1.5; line-height: 1.5;
} }
.money-app .projection-totals {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.money-app .projection-totals > div {
display: grid;
gap: 4px;
padding: 12px 14px;
border: 1px solid var(--site-border);
border-radius: 7px;
}
.money-app .projection-totals strong {
font-size: 16px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.money-app .wish-tier-grid { .money-app .wish-tier-grid {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
border: 1px solid var(--site-border); gap: 6px;
border-radius: 8px;
overflow: hidden;
} }
.money-app .wish-tier-grid > div { .money-app .wish-tier-grid > div {
display: grid; display: grid;
gap: 5px; gap: 3px;
padding: 15px; padding: 9px 11px;
min-width: 0; min-width: 0;
} border: 1px solid var(--site-border);
.money-app .wish-tier-grid > div + div { border-radius: 7px;
border-left: 1px solid var(--site-border);
} }
.money-app .wish-tier-grid strong { .money-app .wish-tier-grid strong {
font-size: 17px; font-size: 14px;
font-weight: 600; font-weight: 600;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -167,21 +124,20 @@
font-size: 13px; font-size: 13px;
} }
.money-app .panel { .money-app .panel {
margin-bottom: 28px;
border: 1px solid var(--site-border); border: 1px solid var(--site-border);
border-radius: 10px; border-radius: 10px;
padding: 24px; padding: 20px;
min-width: 0; min-width: 0;
} }
.money-app .panel-heading { .money-app .panel-heading {
display: flex; display: flex;
align-items: start; align-items: start;
justify-content: space-between; justify-content: space-between;
gap: 20px; gap: 16px;
margin-bottom: 22px; margin-bottom: 16px;
} }
.money-app .panel-heading h2 { .money-app .panel-heading h2 {
font-size: 20px; font-size: 17px;
font-weight: 600; font-weight: 600;
letter-spacing: -0.035em; letter-spacing: -0.035em;
} }
@@ -196,32 +152,12 @@
text-underline-offset: 3px; text-underline-offset: 3px;
white-space: nowrap; white-space: nowrap;
} }
.money-app .two-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 28px;
}
.money-app .summary-list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
border-top: 1px solid var(--site-border);
padding: 12px 0;
font-size: 13px;
}
.money-app .summary-list strong {
font-weight: 500;
}
.money-app .summary-list small {
display: block;
color: var(--site-muted);
font-size: 11px;
margin-top: 2px;
}
.money-app .negative { .money-app .negative {
color: #e5484d; color: #e5484d;
} }
.money-app .positive {
color: #46a758;
}
.money-app .empty { .money-app .empty {
color: var(--site-muted); color: var(--site-muted);
font-size: 13px; font-size: 13px;
@@ -262,12 +198,12 @@
text-align: left; text-align: left;
font-weight: 500; font-weight: 500;
border-bottom: 1px solid var(--site-border); border-bottom: 1px solid var(--site-border);
padding: 10px 12px; padding: 8px 10px;
white-space: nowrap; white-space: nowrap;
} }
.money-app td { .money-app td {
border-bottom: 1px solid var(--site-border); border-bottom: 1px solid var(--site-border);
padding: 11px 12px; padding: 8px 10px;
vertical-align: top; vertical-align: top;
} }
.money-app tbody tr:hover { .money-app tbody tr:hover {
@@ -314,35 +250,11 @@
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.money-app .planning-intro {
display: flex;
align-items: end;
justify-content: space-between;
gap: 32px;
border-bottom: 1px solid var(--site-border);
padding: 8px 0 30px;
margin-bottom: 28px;
}
.money-app .planning-intro > p {
max-width: 370px;
}
.money-app .large-value {
font-size: clamp(1.6rem, 4vw, 2.5rem);
font-weight: 600;
letter-spacing: -0.05em;
margin: 6px 0;
}
.money-app .large-value span {
font-size: 16px;
color: var(--site-muted);
font-weight: 400;
letter-spacing: 0;
}
.money-app .subtabs { .money-app .subtabs {
display: flex; display: flex;
gap: 6px; gap: 6px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 24px; margin-bottom: 16px;
} }
.money-app .subtabs button { .money-app .subtabs button {
border: 1px solid var(--site-border); border: 1px solid var(--site-border);
@@ -356,20 +268,223 @@
color: var(--site-bg); color: var(--site-bg);
border-color: var(--site-text); border-color: var(--site-text);
} }
.money-app .utility-top { .money-app .stat-strip {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 20px; border: 1px solid var(--site-border);
padding-bottom: 16px; border-radius: 10px;
} overflow: hidden;
.money-app .utility-note {
margin-bottom: 16px; margin-bottom: 16px;
} }
.money-app .utility-actions { .money-app .stat {
padding: 16px 18px;
min-width: 0;
}
.money-app .stat + .stat {
border-left: 1px solid var(--site-border);
}
.money-app .stat-value {
margin-top: 4px;
font-size: 1.35rem;
font-weight: 600;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.money-app .stat.net .stat-value {
font-size: 1.75rem;
}
.money-app .dashboard {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
.money-app .span-8 {
grid-column: span 8;
}
.money-app .span-4 {
grid-column: span 4;
}
.money-app .stack {
display: grid;
gap: 16px;
min-width: 0;
}
.money-app .swatch {
display: inline-block;
flex: none;
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--site-border);
}
.money-app .account-cell {
display: inline-flex;
align-items: center;
gap: 7px;
}
.money-app .nowrap {
white-space: nowrap;
}
.money-app tr.future {
color: var(--site-muted);
}
.money-app .chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.money-app .chips button {
display: inline-flex;
align-items: center;
gap: 7px;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 5px 11px;
color: var(--site-muted);
font-size: 12px;
}
.money-app .chips button:hover {
background: var(--site-hover);
}
.money-app .chips button.current {
color: var(--site-text);
border-color: var(--site-text);
background: var(--site-surface);
}
.money-app .item-list li {
display: flex;
align-items: center;
gap: 10px;
border-top: 1px solid var(--site-border);
padding: 8px 0;
font-size: 13px;
}
.money-app .item-list.scroll {
max-height: 360px;
overflow-y: auto;
}
.money-app .item-list li.selected {
background: var(--site-surface);
box-shadow: inset 2px 0 0 var(--site-text);
}
.money-app .item-main {
display: flex;
align-items: center;
gap: 9px;
flex: 1;
min-width: 0;
text-align: left;
padding-left: 4px;
}
.money-app .item-main > span:last-child {
min-width: 0;
}
.money-app .item-main strong {
display: block;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.money-app .item-main small {
display: block;
color: var(--site-muted);
font-size: 11px;
margin-top: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.money-app .item-amount {
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.money-app .item-list .actions button {
width: 26px;
height: 26px;
}
.money-app .tier {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 20px;
height: 20px;
border: 1px solid var(--site-border);
border-radius: 5px;
color: var(--site-muted);
font-size: 11px;
}
.money-app .mini-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px; gap: 8px;
padding-top: 16px; margin-bottom: 12px;
}
.money-app .mini-stats > div {
display: grid;
gap: 3px;
min-width: 0;
}
.money-app .mini-stats strong {
font-size: 15px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.money-app .mini-stats small {
color: var(--site-muted);
font-weight: 400;
}
.money-app .utility-settings {
display: block;
margin: 10px 0 6px;
}
.money-app .utility-note {
margin-top: 12px;
font-size: 12px;
}
@media (max-width: 1180px) {
.money-app .span-8,
.money-app .span-4 {
grid-column: span 12;
}
.money-app .dashboard > .span-4:nth-last-child(-n + 3) {
grid-column: span 6;
}
.money-app .dashboard > .span-4:last-child {
grid-column: span 12;
}
.money-app .stat-strip {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.money-app .stat:nth-child(4) {
border-left: 0;
}
.money-app .stat:nth-child(n + 4) {
border-top: 1px solid var(--site-border);
}
}
@media (max-width: 760px) {
.money-app .dashboard > .span-4:nth-last-child(-n + 3) {
grid-column: span 12;
}
.money-app .stat-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.money-app .stat.net {
grid-column: span 2;
}
.money-app .stat + .stat {
border-left: 0;
border-top: 1px solid var(--site-border);
}
.money-app .stat:nth-child(odd):not(.net) {
border-left: 1px solid var(--site-border);
}
} }
.money-dialog { .money-dialog {
width: min(440px, calc(100% - 28px)); width: min(440px, calc(100% - 28px));
@@ -452,49 +567,13 @@
color: #e5484d; color: #e5484d;
font-size: 13px; font-size: 13px;
} }
@media (max-width: 760px) {
.money-app .overview-grid,
.money-app .two-columns {
grid-template-columns: 1fr;
}
.money-app .overview-side {
border-left: 0;
border-top: 1px solid var(--site-border);
grid-template-columns: 1fr 1fr;
grid-template-rows: auto;
}
.money-app .overview-side > div + div {
border-top: 0;
border-left: 1px solid var(--site-border);
}
.money-app .utility-top {
grid-template-columns: 1fr;
gap: 8px;
}
.money-app .wish-tier-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.money-app .wish-tier-grid > div {
border-left: 0 !important;
border-top: 1px solid var(--site-border);
}
.money-app .planning-intro {
display: block;
}
.money-app .planning-intro > p {
margin-top: 16px;
}
}
@media (max-width: 520px) { @media (max-width: 520px) {
.money-app .projection-totals {
grid-template-columns: 1fr;
}
.money-app .panel { .money-app .panel {
padding: 16px; padding: 16px;
} }
.money-app .net-worth {
padding: 22px;
}
.money-app .overview-side > div {
padding: 16px;
}
.money-app .filters { .money-app .filters {
flex-direction: column; flex-direction: column;
} }