add money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 1m46s

This commit is contained in:
2026-09-23 17:02:32 +00:00
parent 753b2e5d6a
commit 0638ead87f
15 changed files with 2532 additions and 3 deletions

View File

@@ -20,6 +20,7 @@
"db:seed": "bun scripts/seed-catalog.ts", "db:seed": "bun scripts/seed-catalog.ts",
"db:migrate:auth": "bun scripts/migrate-auth-catalog.ts", "db:migrate:auth": "bun scripts/migrate-auth-catalog.ts",
"db:migrate:foundation": "bun scripts/migrate-foundation.ts", "db:migrate:foundation": "bun scripts/migrate-foundation.ts",
"db:migrate:money-tracker": "bun scripts/migrate-money-tracker.ts",
"auth:role": "bun scripts/set-admin.ts", "auth:role": "bun scripts/set-admin.ts",
"catalog:build": "python3 scripts/build-pokemon-catalog.py", "catalog:build": "python3 scripts/build-pokemon-catalog.py",
"test:auth": "node --env-file=.env node_modules/@playwright/test/cli.js test --config playwright.auth.config.ts" "test:auth": "node --env-file=.env node_modules/@playwright/test/cli.js test --config playwright.auth.config.ts"

View File

@@ -30,8 +30,15 @@ try {
to_regclass('public.list_item_completions') is not null as list_completions, to_regclass('public.list_item_completions') is not null as list_completions,
(select count(*) from information_schema.columns where table_name = 'tasks_to_topics' and column_name = 'id') as topic_link_id, (select count(*) from information_schema.columns where table_name = 'tasks_to_topics' and column_name = 'id') as topic_link_id,
(select count(*) from information_schema.columns (select count(*) from information_schema.columns
where table_name in ('animals', 'sightings', 'photos') where table_name in ('animals', 'sightings', 'photos')
and column_name = 'id' and data_type = 'integer') as animal_serial_ids and column_name = 'id' and data_type = 'integer') as animal_serial_ids,
(select count(*) from information_schema.tables
where table_schema = 'public' and table_name in (
'money_accounts', 'money_transactions', 'money_recurring', 'money_forecasts',
'money_utility_settings', 'money_utility_readings', 'money_wishlist', 'money_insurances'
)) as money_tables,
(select count(*) from information_schema.columns
where table_name = 'money_utility_settings' and column_name = 'billing_start') as money_billing_start
`; `;
if ( if (
!Number(schema.user_role) || !Number(schema.user_role) ||
@@ -41,7 +48,9 @@ try {
!Number(schema.list_slug) || !Number(schema.list_slug) ||
!schema.list_completions || !schema.list_completions ||
!Number(schema.topic_link_id) || !Number(schema.topic_link_id) ||
Number(schema.animal_serial_ids) !== 3 Number(schema.animal_serial_ids) !== 3 ||
Number(schema.money_tables) !== 8 ||
!Number(schema.money_billing_start)
) { ) {
throw new Error( throw new Error(
'Required migrations or Animaldex ID conversion have not been applied to this database' 'Required migrations or Animaldex ID conversion have not been applied to this database'

View File

@@ -0,0 +1,25 @@
-- One-off bridge from the pre-foundation site schema to the current table names.
-- Run after the Animaldex UUID conversion, if needed, and before the auth,
-- foundation, and catalog migrations. Review and back up each database first.
-- Renaming keeps existing rows and foreign keys; lists did not exist in this schema.
BEGIN;
ALTER TABLE public.game RENAME TO games;
ALTER TABLE public.pokemon_game_entry RENAME TO pokemon_game_entries;
ALTER TABLE public.user_entry RENAME TO user_entries;
ALTER TABLE public.user_game RENAME TO user_games;
CREATE TABLE public.lists (
id serial PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE public.list_items (
id serial PRIMARY KEY,
list_id integer NOT NULL REFERENCES public.lists(id) ON DELETE CASCADE,
name text NOT NULL
);
CREATE INDEX list_items_list_id_idx ON public.list_items(list_id);
COMMIT;

View File

@@ -0,0 +1,15 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
await tx.unsafe(
await readFile(new URL('./money-tracker-migration.sql', import.meta.url), 'utf8')
);
});
console.log('Money tracker migration applied');
} finally {
await client.end();
}

View File

@@ -0,0 +1,95 @@
CREATE TABLE IF NOT EXISTS money_accounts (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
name text NOT NULL,
opening_balance_cents integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, name)
);
CREATE INDEX IF NOT EXISTS money_accounts_user_id_idx ON money_accounts (user_id);
CREATE TABLE IF NOT EXISTS money_transactions (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
account_id integer NOT NULL REFERENCES money_accounts(id),
date date NOT NULL,
description text NOT NULL,
amount_cents integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS money_transactions_user_date_idx ON money_transactions (user_id, date, id);
CREATE INDEX IF NOT EXISTS money_transactions_account_id_idx ON money_transactions (account_id);
CREATE TABLE IF NOT EXISTS money_recurring (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
name text NOT NULL,
amount_cents integer NOT NULL,
cadence text NOT NULL,
account_id integer REFERENCES money_accounts(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS money_recurring_user_id_idx ON money_recurring (user_id);
CREATE TABLE IF NOT EXISTS money_forecasts (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
date date NOT NULL,
description text NOT NULL,
amount_cents integer NOT NULL,
account_id integer REFERENCES money_accounts(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS money_forecasts_user_date_idx ON money_forecasts (user_id, date);
CREATE TABLE IF NOT EXISTS money_utility_settings (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
kind text NOT NULL,
rate_per_unit numeric(12, 4) NOT NULL DEFAULT 0,
monthly_advance_cents integer NOT NULL DEFAULT 0,
billing_start date,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, kind)
);
ALTER TABLE money_utility_settings ADD COLUMN IF NOT EXISTS billing_start date;
CREATE TABLE IF NOT EXISTS money_utility_readings (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
kind text NOT NULL,
date date NOT NULL,
reading numeric(12, 3) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS money_utility_readings_user_kind_date_idx ON money_utility_readings (user_id, kind, date);
CREATE TABLE IF NOT EXISTS money_wishlist (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
name text NOT NULL,
estimated_cents integer,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS money_wishlist_user_id_idx ON money_wishlist (user_id);
CREATE TABLE IF NOT EXISTS money_insurances (
id serial PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
name text NOT NULL,
provider text,
coverage text,
premium_cents integer,
cadence text NOT NULL DEFAULT 'monthly',
created_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);

View File

@@ -0,0 +1,151 @@
<script lang="ts">
import type { ChartSeries } from './charts';
let {
series,
title,
unit = '€'
}: { series: ChartSeries[]; title: string; unit?: string } = $props();
const width = 800;
const height = 260;
const plot = { left: 72, right: 18, top: 14, bottom: 38 };
let values = $derived(series.flatMap((item) => item.points.map((point) => point.value)));
let dates = $derived(
series.flatMap((item) => item.points.map((point) => Date.parse(`${point.date}T12:00:00Z`)))
);
let min = $derived(Math.min(0, ...values));
let max = $derived(Math.max(0, ...values));
let span = $derived(max - min || 1);
let from = $derived(Math.min(...dates));
let until = $derived(Math.max(...dates));
let dateSpan = $derived(until - from || 1);
const money = new Intl.NumberFormat('en-NL', { maximumFractionDigits: 0 });
function x(date: string) {
return (
plot.left +
((Date.parse(`${date}T12:00:00Z`) - from) / dateSpan) * (width - plot.left - plot.right)
);
}
function y(value: number) {
return plot.top + (1 - (value - min) / span) * (height - plot.top - plot.bottom);
}
function path(points: ChartSeries['points']) {
return points
.map(
(point, index) =>
`${index ? 'L' : 'M'}${x(point.date).toFixed(2)},${y(point.value).toFixed(2)}`
)
.join(' ');
}
function dateLabel(value: number) {
return new Date(value).toLocaleDateString('en-NL', {
month: 'short',
year: 'numeric',
timeZone: 'UTC'
});
}
</script>
<figure class="chart">
{#if values.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex: the chart viewport scrolls horizontally on narrow screens -->
<div class="chart-scroll" role="region" tabindex="0" aria-label={title}>
<svg
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label={title}
preserveAspectRatio="xMidYMid meet"
>
{#each [0, 0.5, 1] as fraction (fraction)}
{@const value = max - span * fraction}
<line
x1={plot.left}
x2={width - plot.right}
y1={y(value)}
y2={y(value)}
stroke="var(--site-border)"
stroke-width="1"
/>
<text
x={plot.left - 12}
y={y(value) + 4}
text-anchor="end"
fill="var(--site-muted)"
font-size="11">{unit}{money.format(value / (unit === '€' ? 100 : 1))}</text
>
{/each}
{#each series as item (item.label)}
{#if item.points.length}
<path
d={path(item.points)}
fill="none"
stroke={item.color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
{/if}
{/each}
<text x={plot.left} y={height - 8} fill="var(--site-muted)" font-size="11"
>{dateLabel(from)}</text
>
<text
x={width - plot.right}
y={height - 8}
text-anchor="end"
fill="var(--site-muted)"
font-size="11">{dateLabel(until)}</text
>
</svg>
</div>
{#if series.length > 1}
<figcaption class="legend">
{#each series as item (item.label)}
<span><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span>
{/each}
</figcaption>
{/if}
{:else}
<p class="empty">Add entries to see a chart.</p>
{/if}
</figure>
<style>
.chart {
min-width: 0;
}
.chart-scroll {
overflow-x: auto;
}
svg {
display: block;
width: 100%;
min-width: 540px;
height: auto;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
padding-top: 12px;
color: var(--site-muted);
font-size: 12px;
}
.legend > span {
display: inline-flex;
align-items: center;
gap: 7px;
}
.swatch {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.empty {
padding: 70px 0;
text-align: center;
color: var(--site-muted);
}
</style>

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { accountBalances, balanceSeries, forecastSeries, utilityEstimate } from './charts';
describe('money tracker calculations', () => {
it('keeps balances separate across accounts and preserves transaction order', () => {
const accounts = [
{ id: 1, name: 'Cash', openingBalanceCents: 10000 },
{ id: 2, name: 'Bank', openingBalanceCents: 20000 }
];
const transactions = [
{ accountId: 1, date: '2026-01-01', amountCents: -2500 },
{ accountId: 2, date: '2026-01-02', amountCents: 5000 },
{ accountId: 1, date: '2026-01-03', amountCents: 1000 }
];
expect([...accountBalances(accounts, transactions).values()]).toEqual([8500, 25000]);
expect(balanceSeries(accounts, transactions)[0].points.map((point) => point.value)).toEqual([
10000, 7500, 8500
]);
});
it('projects only future estimates from the current balance', () => {
expect(
forecastSeries(
10000,
[
{ date: '2026-01-01', amountCents: -9999 },
{ date: '2026-10-01', amountCents: -2500 },
{ date: '2026-11-01', amountCents: 5000 }
],
'2026-09-23'
)[0].points.map((point) => point.value)
).toEqual([10000, 7500, 12500]);
});
it('compares estimated usage cost with prorated advances', () => {
const result = utilityEstimate(
[
{ kind: 'gas', date: '2026-01-01', reading: '100' },
{ kind: 'gas', date: '2026-04-01', reading: '130' }
],
'gas',
'1.2500',
2000
);
expect(result?.usage).toBe(30);
expect(result?.estimatedCents).toBe(3750);
expect(result?.advancesCents).toBeGreaterThan(5800);
expect(
utilityEstimate(
[
{ kind: 'gas', date: '2025-01-01', reading: '0' },
{ kind: 'gas', date: '2026-01-01', reading: '100' },
{ kind: 'gas', date: '2026-04-01', reading: '130' }
],
'gas',
'1.2500',
2000,
'2026-01-01'
)?.usage
).toBe(30);
});
});

View File

@@ -0,0 +1,89 @@
export type ChartPoint = { date: string; value: number };
export type ChartSeries = { label: string; color: string; points: ChartPoint[] };
export function balanceSeries(
accounts: { id: number; name: string; openingBalanceCents: number }[],
transactions: { accountId: number; date: string; amountCents: number }[]
): ChartSeries[] {
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
const lastDate = transactions.at(-1)?.date;
return accounts.map((account, index) => {
let balance = account.openingBalanceCents;
const entries = transactions.filter((transaction) => transaction.accountId === account.id);
const points: ChartPoint[] = [];
if (entries.length) points.push({ date: entries[0].date, value: balance });
for (const entry of entries) {
balance += entry.amountCents;
points.push({ date: entry.date, value: balance });
}
if (lastDate && entries.length && lastDate > entries.at(-1)!.date)
points.push({ date: lastDate, value: balance });
return { label: account.name, color: palette[index % palette.length], points };
});
}
export function accountBalances(
accounts: { id: number; openingBalanceCents: number }[],
transactions: { accountId: number; amountCents: number }[]
): Map<number, number> {
const balances = new Map(accounts.map((account) => [account.id, account.openingBalanceCents]));
for (const transaction of transactions)
balances.set(
transaction.accountId,
(balances.get(transaction.accountId) ?? 0) + transaction.amountCents
);
return balances;
}
export function monthlyCents(amountCents: number, cadence: string): number {
return cadence === 'weekly'
? (amountCents * 52) / 12
: cadence === 'yearly'
? amountCents / 12
: amountCents;
}
export function forecastSeries(
currentCents: number,
forecasts: { date: string; amountCents: number }[],
today: string
): ChartSeries[] {
let projected = currentCents;
const points: ChartPoint[] = [{ date: today, value: currentCents }];
for (const item of forecasts
.filter((entry) => entry.date >= today)
.sort((a, b) => a.date.localeCompare(b.date))) {
projected += item.amountCents;
points.push({ date: item.date, value: projected });
}
return [{ label: 'Projected total', color: '#0070f3', points }];
}
export function utilityEstimate(
readings: { date: string; reading: string; kind: string }[],
kind: string,
ratePerUnit: string,
monthlyAdvanceCents: number,
billingStart?: string | null
) {
const byKind = readings.filter(
(reading) => reading.kind === kind && (!billingStart || reading.date >= billingStart)
);
if (byKind.length < 2) return null;
const first = byKind[0];
const last = byKind.at(-1)!;
const usage = Number(last.reading) - Number(first.reading);
const months =
(Date.parse(`${last.date}T12:00:00Z`) - Date.parse(`${first.date}T12:00:00Z`)) /
((365.25 / 12) * 86_400_000);
if (months <= 0 || usage < 0) return null;
const estimatedCents = Math.round(usage * Number(ratePerUnit) * 100);
const advancesCents = Math.round(months * monthlyAdvanceCents);
return {
usage,
months,
estimatedCents,
advancesCents,
differenceCents: advancesCents - estimatedCents
};
}

View File

@@ -6,3 +6,4 @@ export * from './progress';
export * from './catch-em-all'; export * from './catch-em-all';
export * from './animaldex'; export * from './animaldex';
export * from './lists'; export * from './lists';
export * from './money-tracker';

View File

@@ -0,0 +1,130 @@
import { date, index, integer, numeric, pgTable, text, unique } from 'drizzle-orm/pg-core';
import { id, timestamps } from './helpers';
import { user } from './auth';
export const moneyAccounts = pgTable(
'money_accounts',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
openingBalanceCents: integer('opening_balance_cents').notNull().default(0),
...timestamps
},
(table) => [unique().on(table.userId, table.name), index().on(table.userId)]
);
export const moneyTransactions = pgTable(
'money_transactions',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accountId: integer('account_id')
.notNull()
.references(() => moneyAccounts.id),
date: date('date', { mode: 'string' }).notNull(),
description: text('description').notNull(),
amountCents: integer('amount_cents').notNull(),
...timestamps
},
(table) => [index().on(table.userId, table.date, table.id), index().on(table.accountId)]
);
export const moneyRecurring = pgTable(
'money_recurring',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
amountCents: integer('amount_cents').notNull(),
cadence: text('cadence').notNull(),
accountId: integer('account_id').references(() => moneyAccounts.id, { onDelete: 'set null' }),
...timestamps
},
(table) => [index().on(table.userId)]
);
export const moneyForecasts = pgTable(
'money_forecasts',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
date: date('date', { mode: 'string' }).notNull(),
description: text('description').notNull(),
amountCents: integer('amount_cents').notNull(),
accountId: integer('account_id').references(() => moneyAccounts.id, { onDelete: 'set null' }),
...timestamps
},
(table) => [index().on(table.userId, table.date)]
);
export const moneyUtilitySettings = pgTable(
'money_utility_settings',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
kind: text('kind').notNull(),
ratePerUnit: numeric('rate_per_unit', { precision: 12, scale: 4 }).notNull().default('0'),
monthlyAdvanceCents: integer('monthly_advance_cents').notNull().default(0),
billingStart: date('billing_start', { mode: 'string' }),
...timestamps
},
(table) => [unique().on(table.userId, table.kind)]
);
export const moneyUtilityReadings = pgTable(
'money_utility_readings',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
kind: text('kind').notNull(),
date: date('date', { mode: 'string' }).notNull(),
reading: numeric('reading', { precision: 12, scale: 3 }).notNull(),
...timestamps
},
(table) => [index().on(table.userId, table.kind, table.date)]
);
export const moneyWishlist = pgTable(
'money_wishlist',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
estimatedCents: integer('estimated_cents'),
notes: text('notes'),
...timestamps
},
(table) => [index().on(table.userId)]
);
export const moneyInsurances = pgTable(
'money_insurances',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
provider: text('provider'),
coverage: text('coverage'),
premiumCents: integer('premium_cents'),
cadence: text('cadence').notNull().default('monthly'),
...timestamps
},
(table) => [index().on(table.userId)]
);

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { cents, csvDate, parseCsv } from './money-tracker';
describe('transaction import parsing', () => {
it('handles quoted commas and semicolon-separated European amounts', () => {
expect(
parseCsv('date;account;description;amount\n23/09/2026;Cash;"Food; drinks";-12,50')[1]
).toEqual(['23/09/2026', 'Cash', 'Food; drinks', '-12,50']);
expect(
parseCsv('date,account,description,amount\n2026-09-23,Cash,"Food, drinks",-12.50')[1][2]
).toBe('Food, drinks');
expect(csvDate('23/09/2026')).toBe('2026-09-23');
expect(cents('-12,50')).toBe(-1250);
});
it('rejects invalid dates and amounts rather than altering them', () => {
expect(csvDate('31/02/2026')).toBeNull();
expect(cents('1.234,56')).toBeUndefined();
});
});

View File

@@ -0,0 +1,87 @@
import { fail } from '@sveltejs/kit';
export const utilityKinds = ['electricity', 'gas', 'water'] as const;
export const cadences = ['weekly', 'monthly', 'yearly'] as const;
export function textField(form: FormData, key: string, max = 200) {
const value = String(form.get(key) ?? '').trim();
return value.length > 0 && value.length <= max ? value : null;
}
export function optionalText(form: FormData, key: string, max = 2000) {
const value = String(form.get(key) ?? '').trim();
return value.length <= max ? value || null : undefined;
}
export function cents(value: unknown, optional = false): number | null | undefined {
const raw = String(value ?? '')
.trim()
.replace(',', '.');
if (optional && !raw) return null;
if (!/^-?\d{1,9}(?:\.\d{1,2})?$/.test(raw)) return undefined;
const amount = Math.round(Number(raw) * 100);
return Number.isSafeInteger(amount) && Math.abs(amount) <= 1_000_000_000 ? amount : undefined;
}
export function validDate(value: unknown): string | null {
const raw = String(value ?? '').trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null;
const d = new Date(`${raw}T12:00:00Z`);
return !Number.isNaN(d.valueOf()) && d.toISOString().slice(0, 10) === raw ? raw : null;
}
export function positiveId(value: unknown): number | null {
const id = Number(value);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
export function cadence(value: unknown): (typeof cadences)[number] | null {
return cadences.find((item) => item === value) ?? null;
}
export function utilityKind(value: unknown): (typeof utilityKinds)[number] | null {
return utilityKinds.find((item) => item === value) ?? null;
}
export function invalid(message: string) {
return fail(400, { message });
}
export function parseCsv(input: string): string[][] {
const rows: string[][] = [];
const delimiter = input.split(/\r?\n/, 1)[0].includes(';') ? ';' : ',';
let row: string[] = [];
let field = '';
let quoted = false;
for (let i = 0; i < input.length; i++) {
const character = input[i];
if (character === '"') {
if (quoted && input[i + 1] === '"') {
field += '"';
i++;
} else quoted = !quoted;
} else if (character === delimiter && !quoted) {
row.push(field.trim());
field = '';
} else if ((character === '\n' || character === '\r') && !quoted) {
if (character === '\r' && input[i + 1] === '\n') i++;
row.push(field.trim());
if (row.some(Boolean)) rows.push(row);
row = [];
field = '';
} else field += character;
}
if (quoted) throw new Error('CSV has an unclosed quoted field.');
row.push(field.trim());
if (row.some(Boolean)) rows.push(row);
return rows;
}
export function csvDate(value: string): string | null {
const raw = value.trim();
if (validDate(raw)) return raw;
const match = /^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/.exec(raw);
return match
? validDate(`${match[3]}-${match[2].padStart(2, '0')}-${match[1].padStart(2, '0')}`)
: null;
}

View File

@@ -0,0 +1,438 @@
import { db } from '$lib/server/db';
import {
moneyAccounts,
moneyTransactions,
moneyRecurring,
moneyForecasts,
moneyUtilitySettings,
moneyUtilityReadings,
moneyWishlist,
moneyInsurances
} from '$lib/server/db/schema';
import { requireAdmin } from '$lib/server/authorization';
import {
cadence,
cents,
csvDate,
invalid,
optionalText,
parseCsv,
positiveId,
textField,
utilityKind,
validDate
} from '$lib/server/money-tracker';
import { and, asc, desc, eq } from 'drizzle-orm';
import { error, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
if (!event.locals.user) redirect(303, '/login?returnTo=/projects/money-tracker');
const user = requireAdmin(event);
const userId = user.id;
const [
accounts,
transactions,
recurring,
forecasts,
utilitySettings,
utilityReadings,
wishlist,
insurances
] = await Promise.all([
db
.select()
.from(moneyAccounts)
.where(eq(moneyAccounts.userId, userId))
.orderBy(asc(moneyAccounts.name)),
db
.select()
.from(moneyTransactions)
.where(eq(moneyTransactions.userId, userId))
.orderBy(asc(moneyTransactions.date), asc(moneyTransactions.id)),
db
.select()
.from(moneyRecurring)
.where(eq(moneyRecurring.userId, userId))
.orderBy(asc(moneyRecurring.name)),
db
.select()
.from(moneyForecasts)
.where(eq(moneyForecasts.userId, userId))
.orderBy(asc(moneyForecasts.date)),
db.select().from(moneyUtilitySettings).where(eq(moneyUtilitySettings.userId, userId)),
db
.select()
.from(moneyUtilityReadings)
.where(eq(moneyUtilityReadings.userId, userId))
.orderBy(asc(moneyUtilityReadings.date)),
db
.select()
.from(moneyWishlist)
.where(eq(moneyWishlist.userId, userId))
.orderBy(desc(moneyWishlist.createdAt)),
db
.select()
.from(moneyInsurances)
.where(eq(moneyInsurances.userId, userId))
.orderBy(asc(moneyInsurances.name))
]);
return {
accounts,
transactions,
recurring,
forecasts,
utilitySettings,
utilityReadings,
wishlist,
insurances
};
};
async function ownedAccount(userId: string, value: FormDataEntryValue | null, optional = false) {
if (optional && !value) return null;
const id = positiveId(value);
if (!id) error(400, 'Choose an account.');
const [account] = await db
.select({ id: moneyAccounts.id })
.from(moneyAccounts)
.where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, userId)));
if (!account) error(400, 'Choose an account you own.');
return account.id;
}
function recordId(form: FormData) {
return positiveId(form.get('id'));
}
export const actions: Actions = {
saveAccount: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const name = textField(form, 'name', 80);
const openingBalanceCents = cents(form.get('openingBalance'));
if (!name || openingBalanceCents == null)
return invalid('Enter an account name and a valid opening balance.');
const id = recordId(form);
try {
if (id) {
const [row] = await db
.update(moneyAccounts)
.set({ name, openingBalanceCents })
.where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, user.id)))
.returning({ id: moneyAccounts.id });
if (!row) error(404, 'Account not found');
} else await db.insert(moneyAccounts).values({ userId: user.id, name, openingBalanceCents });
} catch (cause) {
if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505')
return invalid('An account with that name already exists.');
throw cause;
}
return { saved: true };
},
deleteAccount: async (event) => {
const user = requireAdmin(event);
const id = recordId(await event.request.formData());
if (!id) return invalid('Invalid account.');
try {
await db
.delete(moneyAccounts)
.where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, user.id)));
} catch (cause) {
if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23503')
return invalid('Remove or move this account’s transactions before deleting it.');
throw cause;
}
return { saved: true };
},
saveTransaction: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const date = validDate(form.get('date'));
const description = textField(form, 'description', 500);
const amountCents = cents(form.get('amount'));
if (!date || !description || amountCents == null)
return invalid('Enter a date, description and valid signed amount.');
const accountId = await ownedAccount(user.id, form.get('accountId'));
if (!accountId) return invalid('Choose an account.');
const values = { date, description, amountCents, accountId };
const id = recordId(form);
if (id) {
const [row] = await db
.update(moneyTransactions)
.set(values)
.where(and(eq(moneyTransactions.id, id), eq(moneyTransactions.userId, user.id)))
.returning({ id: moneyTransactions.id });
if (!row) error(404, 'Transaction not found');
} else await db.insert(moneyTransactions).values({ ...values, userId: user.id });
return { saved: true };
},
deleteTransaction: async (event) => {
const user = requireAdmin(event);
const id = recordId(await event.request.formData());
if (!id) return invalid('Invalid transaction.');
await db
.delete(moneyTransactions)
.where(and(eq(moneyTransactions.id, id), eq(moneyTransactions.userId, user.id)));
return { saved: true };
},
saveRecurring: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const name = textField(form, 'name', 200);
const amountCents = cents(form.get('amount'));
const period = cadence(form.get('cadence'));
if (!name || amountCents == null || !period)
return invalid('Enter a name, valid signed amount and frequency.');
const accountId = await ownedAccount(user.id, form.get('accountId'), true);
const values = { name, amountCents, cadence: period, accountId };
const id = recordId(form);
if (id) {
const [row] = await db
.update(moneyRecurring)
.set(values)
.where(and(eq(moneyRecurring.id, id), eq(moneyRecurring.userId, user.id)))
.returning({ id: moneyRecurring.id });
if (!row) error(404, 'Recurring item not found');
} else await db.insert(moneyRecurring).values({ ...values, userId: user.id });
return { saved: true };
},
deleteRecurring: async (event) => {
const user = requireAdmin(event);
const id = recordId(await event.request.formData());
if (!id) return invalid('Invalid recurring item.');
await db
.delete(moneyRecurring)
.where(and(eq(moneyRecurring.id, id), eq(moneyRecurring.userId, user.id)));
return { saved: true };
},
saveForecast: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const date = validDate(form.get('date'));
const description = textField(form, 'description', 500);
const amountCents = cents(form.get('amount'));
if (!date || !description || amountCents == null)
return invalid('Enter a date, description and valid signed amount.');
const accountId = await ownedAccount(user.id, form.get('accountId'), true);
const values = { date, description, amountCents, accountId };
const id = recordId(form);
if (id) {
const [row] = await db
.update(moneyForecasts)
.set(values)
.where(and(eq(moneyForecasts.id, id), eq(moneyForecasts.userId, user.id)))
.returning({ id: moneyForecasts.id });
if (!row) error(404, 'Forecast not found');
} else await db.insert(moneyForecasts).values({ ...values, userId: user.id });
return { saved: true };
},
deleteForecast: async (event) => {
const user = requireAdmin(event);
const id = recordId(await event.request.formData());
if (!id) return invalid('Invalid forecast.');
await db
.delete(moneyForecasts)
.where(and(eq(moneyForecasts.id, id), eq(moneyForecasts.userId, user.id)));
return { saved: true };
},
saveUtilitySettings: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const kind = utilityKind(form.get('kind'));
const rate = String(form.get('ratePerUnit') ?? '')
.trim()
.replace(',', '.');
const monthlyAdvanceCents = cents(form.get('monthlyAdvance'));
const billingStartRaw = String(form.get('billingStart') ?? '').trim();
const billingStart = billingStartRaw ? validDate(billingStartRaw) : null;
if (
!kind ||
!/^\d{1,7}(?:\.\d{1,4})?$/.test(rate) ||
monthlyAdvanceCents == null ||
monthlyAdvanceCents < 0 ||
(billingStartRaw && !billingStart)
)
return invalid('Enter a non-negative unit rate and monthly advance.');
await db
.insert(moneyUtilitySettings)
.values({ userId: user.id, kind, ratePerUnit: rate, monthlyAdvanceCents, billingStart })
.onConflictDoUpdate({
target: [moneyUtilitySettings.userId, moneyUtilitySettings.kind],
set: { ratePerUnit: rate, monthlyAdvanceCents, billingStart }
});
return { saved: true };
},
saveReading: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const kind = utilityKind(form.get('kind'));
const date = validDate(form.get('date'));
const reading = String(form.get('reading') ?? '')
.trim()
.replace(',', '.');
if (!kind || !date || !/^\d{1,9}(?:\.\d{1,3})?$/.test(reading))
return invalid('Enter a valid meter reading and date.');
const id = recordId(form);
const values = { kind, date, reading };
if (id) {
const [row] = await db
.update(moneyUtilityReadings)
.set(values)
.where(and(eq(moneyUtilityReadings.id, id), eq(moneyUtilityReadings.userId, user.id)))
.returning({ id: moneyUtilityReadings.id });
if (!row) error(404, 'Reading not found');
} else await db.insert(moneyUtilityReadings).values({ ...values, userId: user.id });
return { saved: true };
},
deleteReading: async (event) => {
const user = requireAdmin(event);
const id = recordId(await event.request.formData());
if (!id) return invalid('Invalid reading.');
await db
.delete(moneyUtilityReadings)
.where(and(eq(moneyUtilityReadings.id, id), eq(moneyUtilityReadings.userId, user.id)));
return { saved: true };
},
saveWishlist: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const name = textField(form, 'name', 200);
const estimatedCents = cents(form.get('estimated'), true);
const notes = optionalText(form, 'notes');
if (
!name ||
estimatedCents === undefined ||
notes === undefined ||
(estimatedCents !== null && estimatedCents < 0)
)
return invalid('Enter a name and a valid estimated cost.');
const values = { name, estimatedCents, notes };
const id = recordId(form);
if (id) {
const [row] = await db
.update(moneyWishlist)
.set(values)
.where(and(eq(moneyWishlist.id, id), eq(moneyWishlist.userId, user.id)))
.returning({ id: moneyWishlist.id });
if (!row) error(404, 'Wish list item not found');
} else await db.insert(moneyWishlist).values({ ...values, userId: user.id });
return { saved: true };
},
deleteWishlist: async (event) => {
const user = requireAdmin(event);
const id = recordId(await event.request.formData());
if (!id) return invalid('Invalid wish list item.');
await db
.delete(moneyWishlist)
.where(and(eq(moneyWishlist.id, id), eq(moneyWishlist.userId, user.id)));
return { saved: true };
},
saveInsurance: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const name = textField(form, 'name', 200);
const provider = optionalText(form, 'provider', 200);
const coverage = optionalText(form, 'coverage');
const premiumCents = cents(form.get('premium'), true);
const period = cadence(form.get('cadence'));
if (
!name ||
provider === undefined ||
coverage === undefined ||
premiumCents === undefined ||
(premiumCents !== null && premiumCents < 0) ||
!period
)
return invalid('Enter an insurance name and valid premium.');
const values = { name, provider, coverage, premiumCents, cadence: period };
const id = recordId(form);
if (id) {
const [row] = await db
.update(moneyInsurances)
.set(values)
.where(and(eq(moneyInsurances.id, id), eq(moneyInsurances.userId, user.id)))
.returning({ id: moneyInsurances.id });
if (!row) error(404, 'Insurance not found');
} else await db.insert(moneyInsurances).values({ ...values, userId: user.id });
return { saved: true };
},
deleteInsurance: async (event) => {
const user = requireAdmin(event);
const id = recordId(await event.request.formData());
if (!id) return invalid('Invalid insurance.');
await db
.delete(moneyInsurances)
.where(and(eq(moneyInsurances.id, id), eq(moneyInsurances.userId, user.id)));
return { saved: true };
},
importTransactions: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const file = form.get('file');
if (!(file instanceof File) || !file.size || file.size > 2_000_000)
return invalid('Choose a CSV file under 2 MB.');
let rows: string[][];
try {
rows = parseCsv(await file.text());
} catch (cause) {
return invalid(cause instanceof Error ? cause.message : 'Invalid CSV.');
}
if (rows.length < 2 || rows.length > 20_001)
return invalid('The CSV must have a header and 1–20,000 rows.');
const header = rows.shift()!.map((value) =>
value
.replace(/^\uFEFF/, '')
.trim()
.toLowerCase()
);
const columns = ['date', 'account', 'description', 'amount'].map((key) => header.indexOf(key));
if (columns.some((index) => index < 0))
return invalid('CSV headers must include date, account, description and amount.');
const parsed: { date: string; account: string; description: string; amountCents: number }[] =
[];
for (const [index, row] of rows.entries()) {
const date = csvDate(row[columns[0]] ?? '');
const account = (row[columns[1]] ?? '').trim();
const description = (row[columns[2]] ?? '').trim();
const amountCents = cents(row[columns[3]]);
if (
!date ||
!account ||
account.length > 80 ||
!description ||
description.length > 500 ||
amountCents == null
)
return invalid(`Invalid data in CSV row ${index + 2}. No rows were imported.`);
parsed.push({ date, account, description, amountCents });
}
await db.transaction(async (tx) => {
const existing = await tx
.select()
.from(moneyAccounts)
.where(eq(moneyAccounts.userId, user.id));
const ids = new Map(existing.map((account) => [account.name.toLowerCase(), account.id]));
for (const name of [...new Set(parsed.map((row) => row.account))]) {
if (ids.has(name.toLowerCase())) continue;
const [account] = await tx
.insert(moneyAccounts)
.values({ userId: user.id, name })
.returning({ id: moneyAccounts.id });
ids.set(name.toLowerCase(), account.id);
}
for (let i = 0; i < parsed.length; i += 500) {
await tx.insert(moneyTransactions).values(
parsed.slice(i, i + 500).map((row) => ({
userId: user.id,
accountId: ids.get(row.account.toLowerCase())!,
date: row.date,
description: row.description,
amountCents: row.amountCents
}))
);
}
});
return { imported: parsed.length };
}
};

View File

@@ -0,0 +1,950 @@
<script lang="ts">
import './money-tracker.css';
import { enhance } from '$app/forms';
import Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil';
import Trash2 from 'lucide-svelte/icons/trash-2';
import Upload from 'lucide-svelte/icons/upload';
import Download from 'lucide-svelte/icons/download';
import X from 'lucide-svelte/icons/x';
import MoneyChart from '$lib/money-tracker/MoneyChart.svelte';
import {
accountBalances,
balanceSeries,
forecastSeries,
monthlyCents,
utilityEstimate
} from '$lib/money-tracker/charts';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
type Tab =
'overview' | 'transactions' | 'planning' | 'utilities' | 'wishlist' | 'insurance' | 'accounts';
type Mode =
| 'account'
| 'transaction'
| 'recurring'
| 'forecast'
| 'settings'
| 'reading'
| 'wish'
| 'insurance'
| 'import'
| '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 = [
{ id: 'electricity', label: 'Electricity', unit: 'kWh' },
{ id: 'gas', label: 'Gas', unit: 'm³' },
{ id: 'water', label: 'Water', unit: 'm³' }
] as const;
const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
let tab = $state<Tab>('overview');
let mode = $state<Mode>('transaction');
let active = $state<Record<string, string | number | null>>({});
let deleteAction = $state('');
let showActionError = $state(false);
let search = $state('');
let accountFilter = $state('all');
let pageNumber = $state(1);
let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity');
let dialog: HTMLDialogElement;
let balances = $derived(accountBalances(data.accounts, data.transactions));
let total = $derived([...balances.values()].reduce((sum, value) => sum + value, 0));
let series = $derived(balanceSeries(data.accounts, data.transactions));
let recent = $derived([...data.transactions].reverse().slice(0, 8));
let filtered = $derived(
[...data.transactions]
.reverse()
.filter(
(row) =>
(accountFilter === 'all' || String(row.accountId) === accountFilter) &&
(!search ||
row.description.toLowerCase().includes(search.toLowerCase()) ||
accountName(row.accountId).toLowerCase().includes(search.toLowerCase()))
)
);
let shown = $derived(filtered.slice((pageNumber - 1) * 25, pageNumber * 25));
let monthlyRecurring = $derived(
data.recurring.reduce((sum, item) => sum + monthlyCents(item.amountCents, item.cadence), 0)
);
let projection = $derived(
forecastSeries(total, data.forecasts, new Date().toISOString().slice(0, 10))
);
let utility = $derived(kinds.find((item) => item.id === selectedUtility)!);
let settings = $derived(data.utilitySettings.find((item) => item.kind === selectedUtility));
let readings = $derived(data.utilityReadings.filter((item) => item.kind === selectedUtility));
let estimate = $derived(
utilityEstimate(
data.utilityReadings,
selectedUtility,
settings?.ratePerUnit ?? '0',
settings?.monthlyAdvanceCents ?? 0,
settings?.billingStart
)
);
let utilitySeries = $derived([
{
label: utility.label,
color: '#0070f3',
points: readings.map((item) => ({ date: item.date, value: Number(item.reading) }))
}
]);
function amount(value: number) {
return euro.format(value / 100);
}
function inputAmount(value: unknown) {
return typeof value === 'number' ? (value / 100).toFixed(2) : '';
}
function accountName(id: number | null) {
return data.accounts.find((account) => account.id === id)?.name ?? 'Unassigned';
}
function dateLabel(value: string) {
return new Date(value + 'T12:00:00Z').toLocaleDateString('en-NL', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC'
});
}
function open(next: Mode, record: object = {}, action = '') {
mode = next;
active = record as Record<string, string | number | null>;
deleteAction = action;
showActionError = false;
dialog.showModal();
}
function createForTab() {
open(
tab === 'accounts'
? 'account'
: tab === 'transactions' || tab === 'overview'
? 'transaction'
: tab === 'planning'
? 'recurring'
: tab === 'utilities'
? 'reading'
: tab === 'wishlist'
? 'wish'
: 'insurance'
);
}
function deletion(name: string, id: number, action: string) {
open('delete', { id, name }, action);
}
function exportCsv() {
const escape = (value: string) => '"' + value.replaceAll('"', '""') + '"';
const lines = [
'date,account,description,amount',
...data.transactions.map((item) =>
[
item.date,
accountName(item.accountId),
item.description,
(item.amountCents / 100).toFixed(2)
]
.map(escape)
.join(',')
)
];
const url = URL.createObjectURL(
new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
);
const link = document.createElement('a');
link.href = url;
link.download = 'money-tracker-transactions.csv';
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
</script>
<div class="money-app mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12">
<header class="app-header">
<div>
<p class="muted">Personal finance</p>
<h1>Money tracker</h1>
<p class="muted">One place for every account, plan and household cost.</p>
</div>
<div class="header-actions">
<button class="button secondary" onclick={() => open('import')}
><Upload size={16} /> Import CSV</button
><button class="button primary" onclick={createForTab}
><Plus size={16} /> Add {tab === 'overview' || tab === 'transactions'
? 'transaction'
: tab === 'planning'
? 'recurring item'
: tab === 'utilities'
? 'reading'
: tab === 'wishlist'
? 'wish'
: tab === 'insurance'
? 'policy'
: 'account'}</button
>
</div>
</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="overview-grid" aria-label="Account overview">
<div class="net-worth">
<p class="eyebrow">Across {data.accounts.length} accounts</p>
<p class="net-value">{amount(total)}</p>
<p class="muted">Current combined balance</p>
</div>
<div class="overview-side">
<div>
<p class="eyebrow">Transactions</p>
<p class="side-value">{data.transactions.length.toLocaleString('en-NL')}</p>
</div>
<div>
<p class="eyebrow">Monthly recurring net</p>
<p class="side-value">{amount(Math.round(monthlyRecurring))}</p>
</div>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<h2>Account history</h2>
<p>Balances calculated from opening balances and dated transactions.</p>
</div>
</div>
<MoneyChart {series} title="Account balances over time" />
</section>
<div class="two-columns">
<section class="panel">
<div class="panel-heading">
<h2>Accounts</h2>
<button class="text-button" onclick={() => (tab = 'accounts')}>View all</button>
</div>
{#if data.accounts.length}<ul class="summary-list">
{#each data.accounts as account (account.id)}<li>
<span>{account.name}</span><strong>{amount(balances.get(account.id) ?? 0)}</strong>
</li>{/each}
</ul>{:else}<p class="empty">
Add an account or import your transactions to get started.
</p>{/if}
</section>
<section class="panel">
<div class="panel-heading">
<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>
<h2>Transactions</h2>
<p>{filtered.length} of {data.transactions.length} entries</p>
</div>
<button class="button secondary" onclick={exportCsv} disabled={!data.transactions.length}
><Download size={16} /> Export CSV</button
>
</div>
<div class="filters">
<label class="sr-only" for="transaction-search">Search transactions</label><input
id="transaction-search"
type="search"
placeholder="Search descriptions"
bind:value={search}
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
>
</div>
<div class="table-wrap">
<table>
<thead
><tr
><th>Date</th><th>Description</th><th>Account</th><th class="numeric">Amount</th><th
class="actions-heading">Actions</th
></tr
></thead
><tbody
>{#each shown as row (row.id)}<tr
><td>{dateLabel(row.date)}</td><td class="strong">{row.description}</td><td
>{accountName(row.accountId)}</td
><td class="numeric" class:negative={row.amountCents < 0}
>{amount(row.amountCents)}</td
><td class="actions"
><button
aria-label={'Edit ' + row.description}
onclick={() => open('transaction', row)}><Pencil size={15} /></button
><button
aria-label={'Delete ' + row.description}
onclick={() => deletion(row.description, row.id, 'deleteTransaction')}
><Trash2 size={15} /></button
></td
></tr
>{/each}</tbody
>
</table>
</div>
{#if !shown.length}<p class="empty">
No matching transactions.
</p>{/if}{#if filtered.length > 25}<div class="pagination">
<span>Page {pageNumber} of {Math.ceil(filtered.length / 25)}</span>
<div>
<button
class="button secondary"
disabled={pageNumber === 1}
onclick={() => pageNumber--}>Previous</button
><button
class="button secondary"
disabled={pageNumber * 25 >= filtered.length}
onclick={() => pageNumber++}>Next</button
>
</div>
</div>{/if}
</section>
{:else if tab === 'planning'}
<div class="planning-intro">
<div>
<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 are a reference list. Dated forecasts do not change actual balances.
</p>
</div>
{#if data.forecasts.some((item) => item.date >= new Date().toISOString().slice(0, 10))}
<section class="panel">
<div class="panel-heading">
<div>
<h2>Projected total</h2>
<p>
Current combined balance plus dated future estimates. Recurring items are not
automatically included.
</p>
</div>
</div>
<MoneyChart series={projection} title="Projected combined account balance" />
</section>
{/if}
<section class="panel">
<div class="panel-heading">
<div>
<h2>Recurring income and costs</h2>
<p>Rent, subscriptions, salary and other known repeats.</p>
</div>
<button class="button secondary" onclick={() => open('recurring')}
><Plus size={16} /> Add item</button
>
</div>
<div class="table-wrap">
<table>
<thead
><tr
><th>Name</th><th>Account</th><th>Frequency</th><th class="numeric">Amount</th><th
class="actions-heading">Actions</th
></tr
></thead
><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
class="numeric"
class:negative={row.amountCents < 0}>{amount(row.amountCents)}</td
><td class="actions"
><button aria-label={'Edit ' + row.name} onclick={() => open('recurring', row)}
><Pencil size={15} /></button
><button
aria-label={'Delete ' + row.name}
onclick={() => deletion(row.name, row.id, 'deleteRecurring')}
><Trash2 size={15} /></button
></td
></tr
>{/each}</tbody
>
</table>
</div>
{#if !data.recurring.length}<p class="empty">No recurring items yet.</p>{/if}
</section>
<section class="panel">
<div class="panel-heading">
<div>
<h2>Future estimates</h2>
<p>Dated income and costs you expect.</p>
</div>
<button class="button secondary" onclick={() => open('forecast')}
><Plus size={16} /> Add estimate</button
>
</div>
<div class="table-wrap">
<table>
<thead
><tr
><th>Date</th><th>Description</th><th>Account</th><th class="numeric">Amount</th><th
class="actions-heading">Actions</th
></tr
></thead
><tbody
>{#each data.forecasts as row (row.id)}<tr
><td>{dateLabel(row.date)}</td><td class="strong">{row.description}</td><td
>{row.accountId ? accountName(row.accountId) : 'Unassigned'}</td
><td class="numeric" class:negative={row.amountCents < 0}
>{amount(row.amountCents)}</td
><td class="actions"
><button
aria-label={'Edit ' + row.description}
onclick={() => open('forecast', row)}><Pencil size={15} /></button
><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>
{:else if tab === 'utilities'}
<section class="panel">
<div class="panel-heading">
<div>
<h2>Utilities</h2>
<p>Meter readings, estimated variable cost and monthly advances.</p>
</div>
</div>
<div class="subtabs">
{#each kinds as kind (kind.id)}<button
class:current={selectedUtility === kind.id}
onclick={() => (selectedUtility = kind.id)}>{kind.label}</button
>{/each}
</div>
<div class="utility-top">
<div>
<p class="eyebrow">Usage in billing period</p>
<p class="large-value">
{estimate ? estimate.usage.toLocaleString('en-NL', { maximumFractionDigits: 3 }) : '—'}
<span>{utility.unit}</span>
</p>
</div>
<div>
<p class="eyebrow">Estimated variable cost</p>
<p class="large-value">{estimate ? amount(estimate.estimatedCents) : '—'}</p>
</div>
<div>
<p class="eyebrow">Advances less estimated cost</p>
<p class="large-value">{estimate ? amount(estimate.differenceCents) : '—'}</p>
</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
series={utilitySeries}
title={utility.label + ' meter readings over time'}
unit=""
/>
<div class="utility-actions">
<button
class="button secondary"
onclick={() =>
open('settings', {
kind: selectedUtility,
ratePerUnit: settings?.ratePerUnit ?? '0',
monthlyAdvanceCents: settings?.monthlyAdvanceCents ?? 0,
billingStart: settings?.billingStart ?? ''
})}>Edit rate and advance</button
><button class="button primary" onclick={() => open('reading', { kind: selectedUtility })}
><Plus size={16} /> Add reading</button
>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<h2>{utility.label} readings</h2>
<p>{readings.length} recorded</p>
</div>
<div class="table-wrap">
<table>
<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(
utility.label + ' reading on ' + dateLabel(row.date),
row.id,
'deleteReading'
)}><Trash2 size={15} /></button
></td
></tr
>{/each}</tbody
>
</table>
</div>
{#if !readings.length}<p class="empty">Add at least two readings to calculate usage.</p>{/if}
</section>
{:else if tab === 'wishlist'}
<section class="panel">
<div class="panel-heading">
<div>
<h2>Wish list</h2>
<p>Ideas for later, kept outside your forecasts.</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead
><tr
><th>Item</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.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>
{#if !data.wishlist.length}<p class="empty">Nothing on your wish list yet.</p>{/if}
</section>
{:else if tab === 'insurance'}
<section class="panel">
<div class="panel-heading">
<div>
<h2>Insurance</h2>
<p>Policies, providers, coverage and premiums in one place.</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead
><tr
><th>Policy</th><th>Provider</th><th>Coverage</th><th class="numeric">Premium</th><th
class="actions-heading">Actions</th
></tr
></thead
><tbody
>{#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
? '—'
: amount(row.premiumCents) + ' / ' + row.cadence}</td
><td class="actions"
><button aria-label={'Edit ' + row.name} onclick={() => open('insurance', row)}
><Pencil size={15} /></button
><button
aria-label={'Delete ' + row.name}
onclick={() => deletion(row.name, row.id, 'deleteInsurance')}
><Trash2 size={15} /></button
></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>
{: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 class="table-wrap">
<table>
<thead
><tr
><th>Account</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="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>
<dialog bind:this={dialog} class="money-dialog" onclose={() => (active = {})}>
{#if showActionError && form?.message}<p class="form-error" role="alert">{form.message}</p>{/if}
<div class="dialog-header">
<h2>
{mode === 'delete'
? 'Delete item'
: mode === 'import'
? 'Import transactions'
: mode === 'settings'
? 'Utility settings'
: (active.id ? 'Edit ' : 'Add ') +
(mode === 'wish' ? 'wish list item' : mode === 'reading' ? 'meter reading' : mode)}
</h2>
<button class="icon-button" aria-label="Close dialog" onclick={() => dialog.close()}
><X size={18} /></button
>
</div>
{#if mode === 'delete'}
<p class="dialog-copy">Delete <strong>{active.name}</strong>? This cannot be undone.</p>
<form
method="POST"
action={'?/' + deleteAction}
use:enhance={() =>
async ({ result, update }) => {
await update();
showActionError = result.type === 'failure';
if (result.type === 'success') dialog.close();
}}
>
<input type="hidden" name="id" value={active.id ?? ''} />
<div class="dialog-footer">
<button type="button" class="button secondary" onclick={() => dialog.close()}>Cancel</button
><button class="button danger">Delete</button>
</div>
</form>
{:else if mode === 'import'}
<p class="dialog-copy">
Upload a CSV with <code>date,account,description,amount</code> columns. Dates can be YYYY-MM-DD
or DD/MM/YYYY. Amounts are signed euros. New account names are created automatically. Importing
a file twice adds duplicate transactions.
</p>
<form
method="POST"
action="?/importTransactions"
enctype="multipart/form-data"
use:enhance={() =>
async ({ result, update }) => {
await update();
showActionError = result.type === 'failure';
if (result.type === 'success') dialog.close();
}}
>
<label class="field"
>CSV file<input type="file" name="file" accept=".csv,text/csv" required /></label
>
<div class="dialog-footer">
<button type="button" class="button secondary" onclick={() => dialog.close()}>Cancel</button
><button class="button primary">Import</button>
</div>
</form>
{:else}
<form
method="POST"
action={'?/' +
(mode === 'account'
? 'saveAccount'
: mode === 'transaction'
? 'saveTransaction'
: mode === 'recurring'
? 'saveRecurring'
: mode === 'forecast'
? 'saveForecast'
: mode === 'settings'
? 'saveUtilitySettings'
: mode === 'reading'
? 'saveReading'
: mode === 'wish'
? 'saveWishlist'
: 'saveInsurance')}
use:enhance={() =>
async ({ result, update }) => {
await update({ reset: false });
showActionError = result.type === 'failure';
if (result.type === 'success') dialog.close();
}}
>
{#if active.id}<input type="hidden" name="id" value={active.id} />{/if}
{#if mode === 'account'}
<label class="field"
>Account name<input
name="name"
required
maxlength="80"
value={active.name ?? ''}
placeholder="Revolut"
/></label
>
<label class="field"
>Opening balance (€)<input
name="openingBalance"
type="number"
step="0.01"
required
value={inputAmount(active.openingBalanceCents)}
/><small>Balance before the first transaction you record.</small></label
>
{:else if mode === 'transaction' || mode === 'forecast'}
<label class="field"
>Date<input
name="date"
type="date"
required
value={active.date ?? new Date().toISOString().slice(0, 10)}
/></label
>
<label class="field"
>Description<input
name="description"
required
maxlength="500"
value={active.description ?? ''}
placeholder="What was this for?"
/></label
>
<label class="field"
>Account<select name="accountId" required={mode === 'transaction'}
><option value="">{mode === 'forecast' ? 'Unassigned' : 'Select an account'}</option
>{#each data.accounts as account (account.id)}<option
value={account.id}
selected={active.accountId === account.id}>{account.name}</option
>{/each}</select
></label
>
<label class="field"
>Amount (€)<input
name="amount"
type="number"
step="0.01"
required
value={inputAmount(active.amountCents)}
placeholder="-12.50"
/><small>Positive for money in, negative for money out.</small></label
>
{:else if mode === 'recurring'}
<label class="field"
>Name<input
name="name"
required
maxlength="200"
value={active.name ?? ''}
placeholder="Rent"
/></label
>
<label class="field"
>Amount (€)<input
name="amount"
type="number"
step="0.01"
required
value={inputAmount(active.amountCents)}
placeholder="-850.00"
/></label
>
<label class="field"
>Frequency<select name="cadence"
><option value="weekly" selected={active.cadence === 'weekly'}>Weekly</option><option
value="monthly"
selected={!active.cadence || active.cadence === 'monthly'}>Monthly</option
><option value="yearly" selected={active.cadence === 'yearly'}>Yearly</option></select
></label
>
<label class="field"
>Account<select name="accountId"
><option value="">Any account</option
>{#each data.accounts as account (account.id)}<option
value={account.id}
selected={active.accountId === account.id}>{account.name}</option
>{/each}</select
></label
>
{:else if mode === 'settings'}
<input type="hidden" name="kind" value={active.kind ?? selectedUtility} />
<label class="field"
>Rate per {utility.unit} (€)<input
name="ratePerUnit"
type="number"
min="0"
step="0.0001"
required
value={active.ratePerUnit ?? '0'}
/></label
>
<label class="field"
>Monthly advance (€)<input
name="monthlyAdvance"
type="number"
min="0"
step="0.01"
required
value={inputAmount(active.monthlyAdvanceCents)}
/></label
>
<label class="field"
>Billing period start<input
name="billingStart"
type="date"
value={active.billingStart ?? ''}
/><small
>Use the date of a meter reading at the start of your annual settlement period.</small
></label
>
{:else if mode === 'reading'}
<label class="field"
>Utility<select name="kind"
>{#each kinds as kind (kind.id)}<option
value={kind.id}
selected={(active.kind ?? selectedUtility) === kind.id}>{kind.label}</option
>{/each}</select
></label
>
<label class="field"
>Date<input
name="date"
type="date"
required
value={active.date ?? new Date().toISOString().slice(0, 10)}
/></label
>
<label class="field"
>Meter reading<input
name="reading"
type="number"
min="0"
step="0.001"
required
value={active.reading ?? ''}
/></label
>
{:else if mode === 'wish'}
<label class="field"
>Item<input name="name" required maxlength="200" value={active.name ?? ''} /></label
>
<label class="field"
>Estimated cost (€)<input
name="estimated"
type="number"
min="0"
step="0.01"
value={inputAmount(active.estimatedCents)}
/></label
>
<label class="field"
>Notes<textarea name="notes" maxlength="2000" rows="3">{active.notes ?? ''}</textarea
></label
>
{:else if mode === 'insurance'}
<label class="field"
>Policy name<input
name="name"
required
maxlength="200"
value={active.name ?? ''}
/></label
>
<label class="field"
>Provider<input name="provider" maxlength="200" value={active.provider ?? ''} /></label
>
<label class="field"
>What it covers<textarea name="coverage" maxlength="2000" rows="3"
>{active.coverage ?? ''}</textarea
></label
>
<label class="field"
>Premium (€)<input
name="premium"
type="number"
min="0"
step="0.01"
value={inputAmount(active.premiumCents)}
/></label
>
<label class="field"
>Frequency<select name="cadence"
><option value="weekly" selected={active.cadence === 'weekly'}>Weekly</option><option
value="monthly"
selected={!active.cadence || active.cadence === 'monthly'}>Monthly</option
><option value="yearly" selected={active.cadence === 'yearly'}>Yearly</option></select
></label
>
{/if}
<div class="dialog-footer">
<button type="button" class="button secondary" onclick={() => dialog.close()}>Cancel</button
><button class="button primary">Save</button>
</div>
</form>
{/if}
</dialog>

View File

@@ -0,0 +1,456 @@
.money-app {
min-height: 70vh;
}
.money-app button,
.money-dialog button {
cursor: pointer;
}
.money-app button:disabled,
.money-dialog button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.money-app .button,
.money-dialog .button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--site-border);
border-radius: 7px;
min-height: 38px;
padding: 0 13px;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
.money-app .button.primary,
.money-dialog .button.primary {
color: var(--site-bg);
background: var(--site-text);
border-color: var(--site-text);
}
.money-app .button.primary:hover,
.money-dialog .button.primary:hover {
opacity: 0.82;
}
.money-app .button.secondary,
.money-dialog .button.secondary {
background: var(--site-surface);
}
.money-app .button.secondary:hover,
.money-dialog .button.secondary:hover,
.money-app .tabs button:hover,
.money-app .subtabs button:hover {
background: var(--site-hover);
}
.money-dialog .button.danger {
background: #b42318;
border-color: #b42318;
color: white;
}
.money-app .app-header {
display: flex;
flex-wrap: wrap;
align-items: end;
justify-content: space-between;
gap: 24px;
border-bottom: 1px solid var(--site-border);
padding-bottom: 30px;
}
.money-app h1 {
margin: 2px 0 8px;
font-size: clamp(2.25rem, 5vw, 3rem);
font-weight: 600;
letter-spacing: -0.05em;
line-height: 1.15;
}
.money-app .header-actions {
display: flex;
flex-wrap: wrap;
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 .eyebrow {
font-size: 12px;
color: var(--site-muted);
}
.money-app .muted {
color: var(--site-muted);
font-size: 13px;
}
.money-app .panel {
margin-bottom: 28px;
border: 1px solid var(--site-border);
border-radius: 10px;
padding: 24px;
min-width: 0;
}
.money-app .panel-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: 20px;
margin-bottom: 22px;
}
.money-app .panel-heading h2 {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.035em;
}
.money-app .panel-heading p {
color: var(--site-muted);
font-size: 13px;
margin-top: 3px;
}
.money-app .text-button {
font-size: 13px;
text-decoration: underline;
text-underline-offset: 3px;
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 {
color: #e5484d;
}
.money-app .empty {
color: var(--site-muted);
font-size: 13px;
padding: 30px 0;
}
.money-app .filters {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.money-app .filters input {
min-width: 0;
flex: 1;
}
.money-app .filters input,
.money-app .filters select,
.money-dialog .field input,
.money-dialog .field select,
.money-dialog .field textarea {
border: 1px solid var(--site-border);
border-radius: 6px;
background: var(--site-bg);
color: var(--site-text);
min-height: 38px;
padding: 8px 10px;
font-size: 13px;
}
.money-app .table-wrap {
overflow-x: auto;
}
.money-app table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.money-app th {
color: var(--site-muted);
text-align: left;
font-weight: 500;
border-bottom: 1px solid var(--site-border);
padding: 10px 12px;
white-space: nowrap;
}
.money-app td {
border-bottom: 1px solid var(--site-border);
padding: 11px 12px;
vertical-align: top;
}
.money-app tbody tr:hover {
background: var(--site-surface);
}
.money-app table .numeric {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.money-app table .strong {
font-weight: 500;
}
.money-app .actions-heading {
text-align: right;
}
.money-app .actions {
white-space: nowrap;
text-align: right;
}
.money-app .actions button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 5px;
color: var(--site-muted);
}
.money-app .actions button:hover {
color: var(--site-text);
background: var(--site-hover);
}
.money-app .pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--site-muted);
font-size: 13px;
margin-top: 16px;
}
.money-app .pagination > div {
display: flex;
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 {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 24px;
}
.money-app .subtabs button {
border: 1px solid var(--site-border);
border-radius: 6px;
padding: 7px 12px;
color: var(--site-muted);
font-size: 13px;
}
.money-app .subtabs button.current {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.money-app .utility-top {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
padding-bottom: 16px;
}
.money-app .utility-note {
margin-bottom: 16px;
}
.money-app .utility-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-top: 16px;
}
.money-dialog {
width: min(440px, calc(100% - 28px));
max-height: min(90vh, 850px);
overflow-y: auto;
margin: auto;
padding: 24px;
border: 1px solid var(--site-border);
border-radius: 10px;
background: var(--site-surface);
color: var(--site-text);
box-shadow: 0 18px 60px #0006;
}
.money-dialog::backdrop {
background: #0009;
}
.money-dialog .dialog-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
margin-bottom: 22px;
}
.money-dialog .dialog-header h2 {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.035em;
}
.money-dialog .icon-button {
display: inline-flex;
padding: 4px;
border-radius: 5px;
}
.money-dialog .icon-button:hover {
background: var(--site-hover);
}
.money-dialog .dialog-copy {
color: var(--site-muted);
font-size: 13px;
line-height: 1.6;
margin-bottom: 20px;
}
.money-dialog .dialog-copy code {
color: var(--site-text);
}
.money-dialog .field {
display: grid;
gap: 6px;
margin-bottom: 16px;
font-size: 13px;
font-weight: 500;
}
.money-dialog .field input,
.money-dialog .field select,
.money-dialog .field textarea {
width: 100%;
font-weight: 400;
}
.money-dialog .field small {
color: var(--site-muted);
font-size: 11px;
font-weight: 400;
}
.money-dialog .dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
.money-dialog .form-error {
color: #e5484d;
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 .planning-intro {
display: block;
}
.money-app .planning-intro > p {
margin-top: 16px;
}
}
@media (max-width: 520px) {
.money-app .panel {
padding: 16px;
}
.money-app .net-worth {
padding: 22px;
}
.money-app .overview-side > div {
padding: 16px;
}
.money-app .filters {
flex-direction: column;
}
.money-app .filters select {
width: 100%;
}
}