add money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 1m46s
All checks were successful
Test and deploy staging / staging (push) Successful in 1m46s
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"db:seed": "bun scripts/seed-catalog.ts",
|
||||
"db:migrate:auth": "bun scripts/migrate-auth-catalog.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",
|
||||
"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"
|
||||
|
||||
@@ -30,8 +30,15 @@ try {
|
||||
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 in ('animals', 'sightings', 'photos')
|
||||
and column_name = 'id' and data_type = 'integer') as animal_serial_ids
|
||||
where table_name in ('animals', 'sightings', 'photos')
|
||||
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 (
|
||||
!Number(schema.user_role) ||
|
||||
@@ -41,7 +48,9 @@ try {
|
||||
!Number(schema.list_slug) ||
|
||||
!schema.list_completions ||
|
||||
!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(
|
||||
'Required migrations or Animaldex ID conversion have not been applied to this database'
|
||||
|
||||
25
scripts/legacy-schema-migration.sql
Normal file
25
scripts/legacy-schema-migration.sql
Normal 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;
|
||||
15
scripts/migrate-money-tracker.ts
Normal file
15
scripts/migrate-money-tracker.ts
Normal 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();
|
||||
}
|
||||
95
scripts/money-tracker-migration.sql
Normal file
95
scripts/money-tracker-migration.sql
Normal 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);
|
||||
151
src/lib/money-tracker/MoneyChart.svelte
Normal file
151
src/lib/money-tracker/MoneyChart.svelte
Normal 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>
|
||||
62
src/lib/money-tracker/charts.test.ts
Normal file
62
src/lib/money-tracker/charts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
89
src/lib/money-tracker/charts.ts
Normal file
89
src/lib/money-tracker/charts.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from './progress';
|
||||
export * from './catch-em-all';
|
||||
export * from './animaldex';
|
||||
export * from './lists';
|
||||
export * from './money-tracker';
|
||||
|
||||
130
src/lib/server/db/schema/money-tracker.ts
Normal file
130
src/lib/server/db/schema/money-tracker.ts
Normal 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)]
|
||||
);
|
||||
20
src/lib/server/money-tracker.test.ts
Normal file
20
src/lib/server/money-tracker.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
87
src/lib/server/money-tracker.ts
Normal file
87
src/lib/server/money-tracker.ts
Normal 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;
|
||||
}
|
||||
438
src/routes/projects/money-tracker/+page.server.ts
Normal file
438
src/routes/projects/money-tracker/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
950
src/routes/projects/money-tracker/+page.svelte
Normal file
950
src/routes/projects/money-tracker/+page.svelte
Normal 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>
|
||||
456
src/routes/projects/money-tracker/money-tracker.css
Normal file
456
src/routes/projects/money-tracker/money-tracker.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user