Files
website/src/routes/trackers/money/+page.svelte
Stan Runge 35f78ec829
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
Money: private Documents page with admin-only file endpoint
Lists contracts/pension docs with written summaries, an inline viewer and
download at /trackers/money/documents. Files and summaries live on the host
(MONEY_DOCUMENTS_ROOT, bind-mounted read-only by deploy-remote.sh), never in
git or static/; the file endpoint is admin-only (401/403) and private, no-store.
2026-10-24 14:32:28 +00:00

1238 lines
42 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { onMount } from 'svelte';
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import { Checkbox } from '#lib/components/ui/checkbox/index.js';
import * as Dialog from '#lib/components/ui/dialog/index.js';
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 FileText from 'lucide-svelte/icons/file-text';
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
import { compactAmount, sortWishlist, tierClass } from '#lib/money-tracker/wishlist.js';
import { Badge } from '#lib/components/ui/badge/index.js';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import * as Tabs from '#lib/components/ui/tabs/index.js';
import {
accountBalances,
accountColor,
clipSeries,
type HistoryRange,
historyStart,
parseHistoryRange,
monthlyCents,
type TimelineView,
utilityEstimate,
wealthProjection,
wealthTimeline
} from '#lib/money-tracker/charts.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
type Mode =
| 'account'
| 'transaction'
| 'recurring'
| 'forecast'
| 'settings'
| 'projectionSettings'
| 'reading'
| 'wish'
| 'insurance'
| 'import'
| 'delete';
const kinds = [
{ id: 'electricity', label: 'Electricity', unit: 'kWh' },
{ id: 'gas', label: 'Gas', unit: 'm³' },
{ id: 'water', label: 'Water', unit: 'm³' }
] as const;
const presetColors = [
'#000000',
'#ffd100',
'#009286',
'#0070f3',
'#e5484d',
'#8e4ec6',
'#e56910'
];
const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
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<number[]>([]);
let sortOrder = $state<'newest' | 'oldest'>('newest');
let pageNumber = $state(1);
let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity');
let dialogOpen = $state(false);
let importAccountId = $state('');
let importNotice = $state('');
let actualTransactions = $derived(data.transactions.filter((item) => item.date <= data.today));
let futureTransactions = $derived(data.transactions.filter((item) => item.date > data.today));
let balances = $derived(accountBalances(data.accounts, actualTransactions));
let totalAccounts = $derived(data.accounts.filter((account) => account.includeInTotal !== false));
let totalIds = $derived(new Set(totalAccounts.map((account) => account.id)));
let total = $derived(
totalAccounts.reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
);
let invested = $derived(
totalAccounts
.filter((account) => account.kind === 'investment')
.reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
);
let colors = $derived(
new Map(data.accounts.map((account, index) => [account.id, accountColor(account, index)]))
);
let monthNet = $derived(
actualTransactions
.filter(
(item) => item.date.slice(0, 7) === data.today.slice(0, 7) && totalIds.has(item.accountId)
)
.reduce((sum, item) => sum + item.amountCents, 0)
);
let monthlyInsurance = $derived(
data.insurances.reduce(
(sum, item) => sum + monthlyCents(item.premiumCents ?? 0, item.cadence),
0
)
);
let filtered = $derived(
(sortOrder === 'newest' ? [...data.transactions].reverse() : data.transactions).filter(
(row) =>
(!accountFilter.length || accountFilter.includes(row.accountId)) &&
(!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
.filter(
(item) =>
(!item.startsOn || item.startsOn <= data.today) &&
(!item.endsOn || item.endsOn >= data.today)
)
.reduce((sum, item) => sum + monthlyCents(item.amountCents, item.cadence), 0)
);
let projection = $derived(
wealthProjection(
data.accounts,
balances,
futureTransactions,
data.forecasts,
data.recurring,
data.projectionSettings,
data.today
)
);
const timelineViews: { id: TimelineView; label: string }[] = [
{ id: 'total', label: 'Total' },
{ id: 'split', label: 'Spendable vs invested' },
{ id: 'accounts', label: 'By account' }
];
let timelineView = $state<TimelineView>('total');
const historyRanges: { id: HistoryRange; label: string }[] = [
{ id: '1y', label: '1 year' },
{ id: '3y', label: '3 years' },
{ id: 'all', label: 'All' }
];
// The range lives in ?range= so it survives reloads; 1y is the default.
let historyRange = $state<HistoryRange>(parseHistoryRange(page.url.searchParams.get('range')));
function setHistoryRange(value: string) {
historyRange = parseHistoryRange(value);
const url = new URL(window.location.href);
url.searchParams.set('range', historyRange);
// Shallow routing: rewrites the current history entry without a load, reload or scroll.
replaceState(url, page.state);
}
let timeline = $derived(
clipSeries(
wealthTimeline(data.accounts, actualTransactions, projection, data.today, timelineView),
historyStart(historyRange, data.today)
)
);
let projectionEnd = $derived(projection.map((line) => line.points.at(-1)?.value ?? 0));
let wishTiers = $derived(
[5, 4, 3, 2, 1].map((tier) => ({
tier,
value: data.wishlist
.filter((item) => item.priority >= tier)
.reduce((sum, item) => sum + (item.estimatedCents ?? 0), 0),
count: data.wishlist.filter((item) => item.priority >= tier).length
}))
);
let sortedWishlist = $derived(sortWishlist(data.wishlist));
let unpricedWishes = $derived(
data.wishlist.filter((item) => item.estimatedCents === null).length
);
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;
dialogOpen = true;
}
function toggleAccount(id: number) {
accountFilter = accountFilter.includes(id)
? accountFilter.filter((item) => item !== id)
: [...accountFilter, id];
pageNumber = 1;
}
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-transactions.csv';
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
</script>
{#snippet signed(value: number)}
<span class:positive={value > 0} class:negative={value < 0}>{amount(value)}</span>
{/snippet}
{#snippet swatch(id: number | null)}
<span class="swatch" style:background={id === null ? 'transparent' : colors.get(id)}></span>
{/snippet}
{#snippet rowActions(label: string, edit: () => void, remove: () => void)}
<span class="actions"
><Button variant="ghost" aria-label={'Edit ' + label} onclick={edit}
><Pencil size={14} /></Button
><Button variant="ghost" aria-label={'Delete ' + label} onclick={remove}
><Trash2 size={14} /></Button
></span
>
{/snippet}
<div class="money-app mx-auto max-w-screen-2xl px-4 py-8 sm:px-6 sm:py-10">
<header class="app-header">
<div>
<p class="muted">Personal finance</p>
<h1>Money</h1>
</div>
<div class="header-actions">
<Button variant="outline" href={resolve('/trackers/money/documents')}
><FileText size={16} /> Documents</Button
><Button variant="outline" onclick={() => open('import')}
><Upload size={16} /> Import CSV</Button
><Button onclick={() => open('transaction')}><Plus size={16} /> Add transaction</Button>
</div>
</header>
<section class="stat-strip" aria-label="Summary">
<div class="stat net">
<p class="eyebrow">Net worth · {totalAccounts.length} accounts</p>
<p class="stat-value">{@render signed(total)}</p>
</div>
<div class="stat">
<p class="eyebrow">Spendable</p>
<p class="stat-value">{@render signed(total - invested)}</p>
</div>
<div class="stat">
<p class="eyebrow">Invested</p>
<p class="stat-value">{@render signed(invested)}</p>
</div>
<div class="stat">
<p class="eyebrow">Net this month</p>
<p class="stat-value">{@render signed(monthNet)}</p>
</div>
<div class="stat">
<p class="eyebrow">Recurring / month</p>
<p class="stat-value">{@render signed(Math.round(monthlyRecurring))}</p>
</div>
<div class="stat">
<p class="eyebrow">
Projected {dateLabel(projection[0].points.at(-1)?.date ?? data.today)}
</p>
<p class="stat-value">{@render signed(projectionEnd[0])}</p>
</div>
</section>
<QuickAdd accounts={data.accounts} transactions={data.transactions} today={data.today} />
{#if importNotice}<p class="mb-4 text-sm" role="status">{importNotice}</p>{/if}
<div class="dashboard">
<section class="panel span-8">
<div class="panel-heading">
<div>
<h2>Wealth over time</h2>
<p>Actual balances through today, then the projection.</p>
</div>
<Button
variant="outline"
onclick={() => open('projectionSettings', data.projectionSettings ?? {})}
>Edit projection</Button
>
</div>
<div class="mb-3 flex flex-wrap gap-2">
<Tabs.Root bind:value={timelineView}
><Tabs.List
>{#each timelineViews as item (item.id)}<Tabs.Trigger value={item.id}
>{item.label}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
<Tabs.Root value={historyRange} onValueChange={setHistoryRange} aria-label="History range"
><Tabs.List
>{#each historyRanges as item (item.id)}<Tabs.Trigger value={item.id}
>{item.label}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
</div>
<MoneyChart
series={timeline}
title="Actual and projected wealth over time"
marker={{ date: data.today, label: 'Today' }}
grid={historyRange === 'all' ? 'years' : 'months'}
/>
<div class="projection-totals" aria-label="Projected balances at end of period">
<div>
<span class="eyebrow">Total at end</span><strong
>{@render signed(projectionEnd[0])}</strong
>
</div>
<div>
<span class="eyebrow">Spendable at end</span><strong
>{@render signed(projectionEnd[1])}</strong
>
</div>
<div>
<span class="eyebrow">Invested at end</span><strong
>{@render signed(projectionEnd[2])}</strong
>
</div>
</div>
<p class="muted projection-note">
Includes future dated transactions and estimates{data.projectionSettings
?.includeRecurring !== false
? ', plus recurring monthly equivalents from next month'
: ''}. {data.projectionSettings?.investmentAccountId
? `The selected investment account and new contributions use a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return.`
: `Excess above ${amount(data.projectionSettings?.reserveCents ?? 100000)} moves into a projected investment balance with a hypothetical ${data.projectionSettings?.annualReturnPercent ?? '5.00'}% annual return.`}
Contributions move money between spendable and invested balances. Enter an item only once across
transactions, estimates, and recurring items to avoid counting it twice.
</p>
</section>
<section class="panel span-4" id="accounts">
<div class="panel-heading">
<div>
<h2>Accounts</h2>
<p>Select accounts to filter transactions.</p>
</div>
<Button
variant="ghost"
disabled={!hydrated}
class="button secondary"
onclick={() => open('account')}><Plus size={16} /> Add</Button
>
</div>
{#if data.accounts.length}<ul class="item-list">
{#each data.accounts as account (account.id)}<li
class:selected={accountFilter.includes(account.id)}
>
<Button
variant="ghost"
type="button"
class="item-main"
aria-pressed={accountFilter.includes(account.id)}
onclick={() => toggleAccount(account.id)}
>{@render swatch(account.id)}<span
><strong>{account.name}</strong><small class="capitalize"
>{account.kind}{#if account.includeInTotal === false}&nbsp;· not in total{/if} · {#if account.bankBalanceAt}bank
{amount(account.bankBalanceCents ?? 0)}, {new Date(
account.bankBalanceAt
).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})}{#if account.bankBalanceCents !== (balances.get(account.id) ?? 0)}<span
class="text-destructive"
>
· differs from tracker</span
>{/if}{:else}opening {amount(account.openingBalanceCents)}{/if}</small
></span
></Button
><strong class="item-amount">{@render signed(balances.get(account.id) ?? 0)}</strong
>{@render rowActions(
account.name,
() => open('account', account),
() => deletion(account.name, account.id, 'deleteAccount')
)}
</li>{/each}
</ul>{:else}<p class="empty">
Add an account or import your transactions to get started.
</p>{/if}
</section>
<section class="panel span-8" id="transactions">
<div class="panel-heading">
<div>
<h2>Transactions</h2>
<p>{filtered.length} of {data.transactions.length} entries</p>
</div>
<div class="header-actions">
<Button variant="outline" href={resolve('/trackers/money/transactions')}
>All transactions</Button
><Button variant="outline" onclick={exportCsv} disabled={!data.transactions.length}
><Download size={16} /> Export CSV</Button
>
</div>
</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="transaction-order">Transaction order</label><NativeSelect
id="transaction-order"
bind:value={sortOrder}
onchange={() => (pageNumber = 1)}
><option value="newest">Newest first</option><option value="oldest">Oldest first</option
></NativeSelect
>
</div>
{#if data.accounts.length}<div class="chips" role="group" aria-label="Filter by account">
<Button
variant="ghost"
type="button"
class={!accountFilter.length ? 'current' : ''}
aria-pressed={!accountFilter.length}
onclick={() => {
accountFilter = [];
pageNumber = 1;
}}>All accounts</Button
>{#each data.accounts as account (account.id)}<Button
variant="ghost"
type="button"
class={accountFilter.includes(account.id) ? 'current' : ''}
aria-pressed={accountFilter.includes(account.id)}
onclick={() => toggleAccount(account.id)}
>{@render swatch(account.id)}{account.name}</Button
>{/each}
</div>{/if}
<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 class:future={row.date > data.today}
><td class="nowrap">{dateLabel(row.date)}</td><td class="strong"
>{row.description}{#if row.source !== 'manual'}
<Badge variant="secondary" class="ml-2"
>{row.source === 'enablebanking' ? 'bank' : row.source}</Badge
>{/if}</td
><td class="nowrap"
><span class="account-cell"
>{@render swatch(row.accountId)}{accountName(row.accountId)}</span
></td
><td class="numeric">{@render signed(row.amountCents)}</td><td class="actions"
><Button
variant="ghost"
aria-label={'Edit ' + row.description}
onclick={() => open('transaction', row)}><Pencil size={14} /></Button
><Button
variant="ghost"
aria-label={'Delete ' + row.description}
onclick={() => deletion(row.description, row.id, 'deleteTransaction')}
><Trash2 size={14} /></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
variant="ghost"
class="button secondary"
disabled={pageNumber === 1}
onclick={() => pageNumber--}>Previous</Button
><Button
variant="ghost"
class="button secondary"
disabled={pageNumber * 25 >= filtered.length}
onclick={() => pageNumber++}>Next</Button
>
</div>
</div>{/if}
</section>
<div class="span-4 stack">
<section class="panel" id="recurring">
<div class="panel-heading">
<div>
<h2>Recurring</h2>
<p>
{@render signed(Math.round(monthlyRecurring))} per month now. Feeds the projection when
enabled.
</p>
</div>
<Button
variant="ghost"
disabled={!hydrated}
class="button secondary"
onclick={() => open('recurring')}><Plus size={16} /> Add</Button
>
</div>
{#if data.recurring.length}<ul class="item-list scroll">
{#each data.recurring as row (row.id)}<li>
<span class="item-main"
>{@render swatch(row.accountId)}<span
><strong>{row.name}</strong><small
><span class="capitalize">{row.cadence}</span> · {row.accountId
? accountName(row.accountId)
: 'Any account'} · {row.startsOn ? dateLabel(row.startsOn) : 'Now'} – {row.endsOn
? dateLabel(row.endsOn)
: 'ongoing'}</small
></span
></span
><strong class="item-amount">{@render signed(row.amountCents)}</strong
>{@render rowActions(
row.name,
() => open('recurring', row),
() => deletion(row.name, row.id, 'deleteRecurring')
)}
</li>{/each}
</ul>{:else}<p class="empty">Rent, subscriptions, salary and other known repeats.</p>{/if}
</section>
<section class="panel" id="estimates">
<div class="panel-heading">
<div>
<h2>Future estimates</h2>
<p>Dated income and costs you expect.</p>
</div>
<Button
variant="ghost"
disabled={!hydrated}
class="button secondary"
onclick={() => open('forecast')}><Plus size={16} /> Add</Button
>
</div>
{#if data.forecasts.length}<ul class="item-list scroll">
{#each data.forecasts as row (row.id)}<li>
<span class="item-main"
>{@render swatch(row.accountId)}<span
><strong>{row.description}</strong><small
>{dateLabel(row.date)} · {row.accountId
? accountName(row.accountId)
: 'Unassigned'}</small
></span
></span
><strong class="item-amount">{@render signed(row.amountCents)}</strong
>{@render rowActions(
row.description,
() => open('forecast', row),
() => deletion(row.description, row.id, 'deleteForecast')
)}
</li>{/each}
</ul>{:else}<p class="empty">Add expected costs or income to look ahead.</p>{/if}
</section>
</div>
<section class="panel span-4" id="utilities">
<div class="panel-heading">
<div>
<h2>Utilities</h2>
<p>Meter readings and estimated variable cost.</p>
</div>
<Button
variant="ghost"
disabled={!hydrated}
class="button secondary"
onclick={() => open('reading', { kind: selectedUtility })}
><Plus size={16} /> Reading</Button
>
</div>
<div class="subtabs">
{#each kinds as kind (kind.id)}<Button
variant="ghost"
class={selectedUtility === kind.id ? 'current' : ''}
onclick={() => (selectedUtility = kind.id)}>{kind.label}</Button
>{/each}
</div>
<div class="mini-stats">
<div>
<span class="eyebrow">Usage</span><strong
>{estimate ? estimate.usage.toLocaleString('en-NL', { maximumFractionDigits: 3 }) : '—'}
<small>{utility.unit}</small></strong
>
</div>
<div>
<span class="eyebrow">Est. cost</span><strong
>{estimate ? amount(estimate.estimatedCents) : '—'}</strong
>
</div>
<div>
<span class="eyebrow">Advances − cost</span><strong
>{#if estimate}{@render signed(estimate.differenceCents)}{:else}—{/if}</strong
>
</div>
</div>
<MoneyChart
series={utilitySeries}
title={utility.label + ' meter readings over time'}
unit=""
/>
<Button
variant="ghost"
class="text-button utility-settings"
onclick={() =>
open('settings', {
kind: selectedUtility,
ratePerUnit: settings?.ratePerUnit ?? '0',
monthlyAdvanceCents: settings?.monthlyAdvanceCents ?? 0,
billingStart: settings?.billingStart ?? ''
})}>Edit rate and advance</Button
>
{#if readings.length}<ul class="item-list scroll">
{#each [...readings].reverse() as row (row.id)}<li>
<span class="item-main"><span><strong>{dateLabel(row.date)}</strong></span></span
><span class="item-amount"
>{Number(row.reading).toLocaleString('en-NL', { maximumFractionDigits: 3 })}
{utility.unit}</span
>{@render rowActions(
'reading from ' + row.date,
() => open('reading', row),
() =>
deletion(
utility.label + ' reading on ' + dateLabel(row.date),
row.id,
'deleteReading'
)
)}
</li>{/each}
</ul>{:else}<p class="empty">Add at least two readings to calculate usage.</p>{/if}
<p class="muted utility-note">
Uses the first and last readings in the billing period, the current unit rate and prorated
monthly advances. Fixed charges, taxes and settlements are not included.
</p>
</section>
<section class="panel span-4" id="wishlist">
<div class="panel-heading">
<div>
<h2>Wish list</h2>
<p>Cumulative cost by priority tier.</p>
</div>
<Button
variant="ghost"
disabled={!hydrated}
class="button secondary"
onclick={() => open('wish')}><Plus size={16} /> Add</Button
>
</div>
<div class="wish-tier-grid">
{#each wishTiers as row (row.tier)}<div class={tierClass(row.tier)}>
<span class="eyebrow">{row.tier === 5 ? 'Tier 5' : `5–${row.tier}`}</span><strong
title={amount(row.value)}
><span class="currency">€&nbsp;</span>{compactAmount(row.value)}</strong
><small>{row.count} {row.count === 1 ? 'item' : 'items'}</small>
</div>{/each}
</div>
{#if unpricedWishes}<p class="muted projection-note">
{unpricedWishes} unpriced {unpricedWishes === 1 ? 'item is' : 'items are'} excluded from these
totals.
</p>{/if}
{#if data.wishlist.length}<ul class="item-list scroll">
{#each sortedWishlist as row (row.id)}<li class="wish {tierClass(row.priority)}">
<span class="item-main"
><span class="tier" title="Tier {row.priority}">{row.priority}</span><span
><strong>{row.name}</strong>{#if row.notes}<small>{row.notes}</small>{/if}</span
></span
><strong class="item-amount"
>{row.estimatedCents == null ? '—' : amount(row.estimatedCents)}</strong
>{@render rowActions(
row.name,
() => open('wish', row),
() => deletion(row.name, row.id, 'deleteWishlist')
)}
</li>{/each}
</ul>{:else}<p class="empty">Nothing on your wish list yet.</p>{/if}
</section>
<section class="panel span-4" id="insurance">
<div class="panel-heading">
<div>
<h2>Insurance</h2>
<p>{amount(Math.round(monthlyInsurance))} per month in premiums.</p>
</div>
<Button
variant="ghost"
disabled={!hydrated}
class="button secondary"
onclick={() => open('insurance')}><Plus size={16} /> Add</Button
>
</div>
{#if data.insurances.length}<ul class="item-list scroll">
{#each data.insurances as row (row.id)}<li>
<span class="item-main"
><span
><strong>{row.name}</strong><small
>{[row.provider, row.coverage].filter(Boolean).join(' · ') || '—'}</small
></span
></span
><strong class="item-amount"
>{row.premiumCents == null
? '—'
: amount(row.premiumCents) + ' / ' + row.cadence}</strong
>{@render rowActions(
row.name,
() => open('insurance', row),
() => deletion(row.name, row.id, 'deleteInsurance')
)}
</li>{/each}
</ul>{:else}<p class="empty">Add policies to keep track of what is covered.</p>{/if}
</section>
</div>
</div>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="money-dialog block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
<Dialog.Description class="sr-only">Manage your money records.</Dialog.Description>
{#if showActionError && form?.message}<p class="form-error" role="alert">{form.message}</p>{/if}
<Dialog.Header class="mb-6 pr-8"
><Dialog.Title>
{mode === 'delete'
? 'Delete item'
: mode === 'import'
? 'Import transactions'
: mode === 'settings'
? 'Utility settings'
: mode === 'projectionSettings'
? 'Wealth projection'
: (active.id ? 'Edit ' : 'Add ') +
(mode === 'wish'
? 'wish list item'
: mode === 'reading'
? 'meter reading'
: mode)}
</Dialog.Title></Dialog.Header
>
{#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') dialogOpen = false;
}}
>
<input type="hidden" name="id" value={active.id ?? ''} />
<div class="dialog-footer">
<Button type="button" class="button secondary" onclick={() => (dialogOpen = false)}
>Cancel</Button
><Button type="submit" class="button danger">Delete</Button>
</div>
</form>
{:else if mode === 'import'}
<p class="dialog-copy">
Upload your CSV or tab-separated export with <code>date, account, description, amount</code>
columns, a Revolut statement export (only completed EUR rows are imported, fees included), or
Knab exports. Knab files go to the account with that IBAN; pick an account to merge several Knab
accounts (current + savings) into one, and transfers between them are left out. Dates like
<code>Fri 1 Aug 2025</code>
and amounts like <code>-€1,234.50</code> are supported. Empty rows are skipped, and entries appear
in date order. “Start balance” rows import as transactions, so leave those accounts’ opening balances
at zero. New account names are created automatically. Rows already imported, or already entered
with the same amount within a day, are skipped.
</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') {
dialogOpen = false;
const done = result.data as
| { imported?: number; duplicates?: number; internal?: number; replaced?: number }
| undefined;
importNotice =
`Imported ${done?.imported ?? 0} transactions; skipped ${done?.duplicates ?? 0} duplicates` +
(done?.internal ? `, ${done.internal} transfers between your own accounts` : '') +
(done?.replaced
? `; replaced ${done.replaced} hand entries that were sums of payments`
: '') +
'.';
}
}}
>
<label class="field"
>CSV or TSV files<Input
type="file"
name="file"
multiple
accept=".csv,.tsv,text/csv,text/tab-separated-values"
required
/></label
>
<label class="field"
>Account (for files without an account column, like Revolut statements, or to merge Knab
accounts)<NativeSelect name="accountId" bind:value={importAccountId}
><option value="">From the file</option
>{#each data.accounts as account (account.id)}<option value={String(account.id)}
>{account.name}</option
>{/each}</NativeSelect
></label
>
<div class="dialog-footer">
<Button type="button" class="button secondary" onclick={() => (dialogOpen = false)}
>Cancel</Button
><Button type="submit" 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 === 'projectionSettings'
? 'saveProjectionSettings'
: mode === 'reading'
? 'saveReading'
: mode === 'wish'
? 'saveWishlist'
: 'saveInsurance')}
use:enhance={() =>
async ({ result, update }) => {
await update({ reset: false });
showActionError = result.type === 'failure';
if (result.type === 'success') dialogOpen = false;
}}
>
{#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="Daily"
/></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
>
<label class="field"
>Account type<NativeSelect name="kind"
><option value="spendable" selected={active.kind !== 'investment'}>Spendable</option
><option value="investment" selected={active.kind === 'investment'}>Investment</option
></NativeSelect
><small
>Investment accounts are included in total wealth but excluded from the cash reserve.</small
></label
>
<label class="field check-field"
><Checkbox name="includeInTotal" value="on" checked={active.includeInTotal !== false} /> Count
toward total wealth</label
>
<label class="field"
>Color<Input
name="color"
type="color"
list="account-colors"
value={active.color ??
(typeof active.id === 'number'
? colors.get(active.id)
: accountColor({}, data.accounts.length))}
/><datalist id="account-colors"
>{#each presetColors as color (color)}<option value={color}></option>{/each}</datalist
><small>Used for the account's chart line, filter chip and transaction rows.</small
></label
>
{:else if mode === 'transaction' || mode === 'forecast'}
<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<NativeSelect 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}</NativeSelect
></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<NativeSelect 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
></NativeSelect
></label
>
<label class="field"
>Account<NativeSelect 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}</NativeSelect
></label
>
<label class="field"
>Starts on<Input name="startsOn" type="date" value={active.startsOn ?? ''} /><small
>Optional. Leave blank for an ongoing item.</small
></label
>
<label class="field"
>Ends on<Input name="endsOn" type="date" value={active.endsOn ?? ''} /></label
>
{:else if mode === 'projectionSettings'}
<p class="dialog-copy">
A planning scenario. Returns are hypothetical and calculated monthly. Contributions
transfer money from spendable accounts into a projected investment balance.
</p>
<label class="field"
>Existing investment account (optional)<NativeSelect name="investmentAccountId"
><option value="">Start projected investments at €0</option
>{#each data.accounts.filter((account) => account.kind === 'investment') as account (account.id)}<option
value={account.id}
selected={active.investmentAccountId === account.id}>{account.name}</option
>{/each}</NativeSelect
><small
>Select an existing investment account to include its current balance in the growth
model.</small
></label
>
<label class="field"
>Spendable reserve (€)<Input
name="reserve"
type="number"
min="0"
step="0.01"
required
value={inputAmount(active.reserveCents ?? 100000)}
/></label
>
<label class="field"
>Hypothetical annual return (%)<Input
name="annualReturnPercent"
type="number"
min="0"
max="100"
step="0.01"
required
value={active.annualReturnPercent ?? '5.00'}
/></label
>
<label class="field"
>Contribution rule<NativeSelect name="contributionMode"
><option value="excess" selected={active.contributionMode !== 'fixed'}
>Invest all above reserve</option
><option value="fixed" selected={active.contributionMode === 'fixed'}
>Fixed monthly amount, up to excess</option
></NativeSelect
></label
>
<label class="field"
>Fixed monthly amount (€)<Input
name="fixedMonthly"
type="number"
min="0"
step="0.01"
required
value={inputAmount(active.fixedMonthlyCents ?? 0)}
/></label
>
<label class="field"
>Project through<Input name="endDate" type="date" value={active.endDate ?? ''} /><small
>Optional, up to 20 years ahead. By default, ends at the latest future entry, or one
year from today.</small
></label
>
<label class="field check-field"
><Checkbox
name="includeRecurring"
value="on"
checked={active.includeRecurring !== false}
/> Include recurring items from next month</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<NativeSelect name="kind"
>{#each kinds as kind (kind.id)}<option
value={kind.id}
selected={(active.kind ?? selectedUtility) === kind.id}>{kind.label}</option
>{/each}</NativeSelect
></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"
>Priority tier<NativeSelect name="priority"
>{#each [5, 4, 3, 2, 1] as tier (tier)}<option
value={tier}
selected={(active.priority ?? 3) === tier}
>Tier {tier}{tier === 5 ? ' · highest' : tier === 1 ? ' · lowest' : ''}</option
>{/each}</NativeSelect
></label
>
<label class="field"
>Notes<Textarea
name="notes"
maxlength={2000}
rows={3}
value={active.notes ?? ''}
/></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}
value={active.coverage ?? ''}
/></label
>
<label class="field"
>Premium (€)<Input
name="premium"
type="number"
min="0"
step="0.01"
value={inputAmount(active.premiumCents)}
/></label
>
<label class="field"
>Frequency<NativeSelect 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
></NativeSelect
></label
>
{/if}
<div class="dialog-footer">
<Button type="button" class="button secondary" onclick={() => (dialogOpen = false)}
>Cancel</Button
><Button type="submit" class="button primary">Save</Button>
</div>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>