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

@@ -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%;
}
}