diff --git a/package.json b/package.json index 447f455..915d992 100755 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/deploy-preflight.ts b/scripts/deploy-preflight.ts index b729eaf..f57f27a 100644 --- a/scripts/deploy-preflight.ts +++ b/scripts/deploy-preflight.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' diff --git a/scripts/legacy-schema-migration.sql b/scripts/legacy-schema-migration.sql new file mode 100644 index 0000000..060b592 --- /dev/null +++ b/scripts/legacy-schema-migration.sql @@ -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; diff --git a/scripts/migrate-money-tracker.ts b/scripts/migrate-money-tracker.ts new file mode 100644 index 0000000..c9d3993 --- /dev/null +++ b/scripts/migrate-money-tracker.ts @@ -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(); +} diff --git a/scripts/money-tracker-migration.sql b/scripts/money-tracker-migration.sql new file mode 100644 index 0000000..ddcc73b --- /dev/null +++ b/scripts/money-tracker-migration.sql @@ -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); diff --git a/src/lib/money-tracker/MoneyChart.svelte b/src/lib/money-tracker/MoneyChart.svelte new file mode 100644 index 0000000..dfd3a7d --- /dev/null +++ b/src/lib/money-tracker/MoneyChart.svelte @@ -0,0 +1,151 @@ + + +
+ {#if values.length} + +
+ + {#each [0, 0.5, 1] as fraction (fraction)} + {@const value = max - span * fraction} + + {unit}{money.format(value / (unit === '€' ? 100 : 1))} + {/each} + {#each series as item (item.label)} + {#if item.points.length} + + {/if} + {/each} + {dateLabel(from)} + {dateLabel(until)} + +
+ {#if series.length > 1} +
+ {#each series as item (item.label)} + {item.label} + {/each} +
+ {/if} + {:else} +

Add entries to see a chart.

+ {/if} +
+ + diff --git a/src/lib/money-tracker/charts.test.ts b/src/lib/money-tracker/charts.test.ts new file mode 100644 index 0000000..10bedbc --- /dev/null +++ b/src/lib/money-tracker/charts.test.ts @@ -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); + }); +}); diff --git a/src/lib/money-tracker/charts.ts b/src/lib/money-tracker/charts.ts new file mode 100644 index 0000000..5ef670f --- /dev/null +++ b/src/lib/money-tracker/charts.ts @@ -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 { + 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 + }; +} diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index b7e6e07..7695822 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -6,3 +6,4 @@ export * from './progress'; export * from './catch-em-all'; export * from './animaldex'; export * from './lists'; +export * from './money-tracker'; diff --git a/src/lib/server/db/schema/money-tracker.ts b/src/lib/server/db/schema/money-tracker.ts new file mode 100644 index 0000000..7f4d13c --- /dev/null +++ b/src/lib/server/db/schema/money-tracker.ts @@ -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)] +); diff --git a/src/lib/server/money-tracker.test.ts b/src/lib/server/money-tracker.test.ts new file mode 100644 index 0000000..f2b98eb --- /dev/null +++ b/src/lib/server/money-tracker.test.ts @@ -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(); + }); +}); diff --git a/src/lib/server/money-tracker.ts b/src/lib/server/money-tracker.ts new file mode 100644 index 0000000..3c032d6 --- /dev/null +++ b/src/lib/server/money-tracker.ts @@ -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; +} diff --git a/src/routes/projects/money-tracker/+page.server.ts b/src/routes/projects/money-tracker/+page.server.ts new file mode 100644 index 0000000..1c96c35 --- /dev/null +++ b/src/routes/projects/money-tracker/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/projects/money-tracker/+page.svelte b/src/routes/projects/money-tracker/+page.svelte new file mode 100644 index 0000000..29bb276 --- /dev/null +++ b/src/routes/projects/money-tracker/+page.svelte @@ -0,0 +1,950 @@ + + +
+
+
+

Personal finance

+

Money tracker

+

One place for every account, plan and household cost.

+
+
+ +
+
+ + + {#if tab === 'overview'} +
+
+

Across {data.accounts.length} accounts

+

{amount(total)}

+

Current combined balance

+
+
+
+

Transactions

+

{data.transactions.length.toLocaleString('en-NL')}

+
+
+

Monthly recurring net

+

{amount(Math.round(monthlyRecurring))}

+
+
+
+
+
+
+

Account history

+

Balances calculated from opening balances and dated transactions.

+
+
+ +
+
+
+
+

Accounts

+ +
+ {#if data.accounts.length}
    + {#each data.accounts as account (account.id)}
  • + {account.name}{amount(balances.get(account.id) ?? 0)} +
  • {/each} +
{:else}

+ Add an account or import your transactions to get started. +

{/if} +
+
+
+

Recent transactions

+ +
+ {#if recent.length}
    + {#each recent as row (row.id)}
  • + {row.description}{accountName(row.accountId)} · {dateLabel(row.date)}{amount(row.amountCents)} +
  • {/each} +
{:else}

+ Transactions will appear here after you add or import them. +

{/if} +
+
+ {:else if tab === 'transactions'} +
+
+
+

Transactions

+

{filtered.length} of {data.transactions.length} entries

+
+ +
+
+ (pageNumber = 1)} + /> +
+
+ + {#each shown as row (row.id)}{/each} +
DateDescriptionAccountAmountActions
{dateLabel(row.date)}{row.description}{accountName(row.accountId)}{amount(row.amountCents)}
+
+ {#if !shown.length}

+ No matching transactions. +

{/if}{#if filtered.length > 25}{/if} +
+ {:else if tab === 'planning'} +
+
+

Monthly equivalent

+

{amount(Math.round(monthlyRecurring))}

+

Signed total of recurring income and costs

+
+

+ Recurring items are a reference list. Dated forecasts do not change actual balances. +

+
+ {#if data.forecasts.some((item) => item.date >= new Date().toISOString().slice(0, 10))} +
+
+
+

Projected total

+

+ Current combined balance plus dated future estimates. Recurring items are not + automatically included. +

+
+
+ +
+ {/if} +
+
+
+

Recurring income and costs

+

Rent, subscriptions, salary and other known repeats.

+
+ +
+
+ + {#each data.recurring as row (row.id)}{/each} +
NameAccountFrequencyAmountActions
{row.name}{row.accountId ? accountName(row.accountId) : 'Any'}{row.cadence}{amount(row.amountCents)}
+
+ {#if !data.recurring.length}

No recurring items yet.

{/if} +
+
+
+
+

Future estimates

+

Dated income and costs you expect.

+
+ +
+
+ + {#each data.forecasts as row (row.id)}{/each} +
DateDescriptionAccountAmountActions
{dateLabel(row.date)}{row.description}{row.accountId ? accountName(row.accountId) : 'Unassigned'}{amount(row.amountCents)}
+
+ {#if !data.forecasts.length}

+ Add expected costs or income to look ahead. +

{/if} +
+ {:else if tab === 'utilities'} +
+
+
+

Utilities

+

Meter readings, estimated variable cost and monthly advances.

+
+
+
+ {#each kinds as kind (kind.id)}{/each} +
+
+
+

Usage in billing period

+

+ {estimate ? estimate.usage.toLocaleString('en-NL', { maximumFractionDigits: 3 }) : '—'} + {utility.unit} +

+
+
+

Estimated variable cost

+

{estimate ? amount(estimate.estimatedCents) : '—'}

+
+
+

Advances less estimated cost

+

{estimate ? amount(estimate.differenceCents) : '—'}

+
+
+

+ 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. +

+ +
+ +
+
+
+
+

{utility.label} readings

+

{readings.length} recorded

+
+
+ + {#each [...readings].reverse() as row (row.id)}{/each} +
DateMeter value ({utility.unit})Actions
{dateLabel(row.date)}{Number(row.reading).toLocaleString('en-NL', { maximumFractionDigits: 3 })}
+
+ {#if !readings.length}

Add at least two readings to calculate usage.

{/if} +
+ {:else if tab === 'wishlist'} +
+
+
+

Wish list

+

Ideas for later, kept outside your forecasts.

+
+
+
+ + {#each data.wishlist as row (row.id)}{/each} +
ItemNotesEstimated costActions
{row.name}{row.notes ?? '—'}{row.estimatedCents == null ? '—' : amount(row.estimatedCents)}
+
+ {#if !data.wishlist.length}

Nothing on your wish list yet.

{/if} +
+ {:else if tab === 'insurance'} +
+
+
+

Insurance

+

Policies, providers, coverage and premiums in one place.

+
+
+
+ + {#each data.insurances as row (row.id)}{/each} +
PolicyProviderCoveragePremiumActions
{row.name}{row.provider ?? '—'}{row.coverage ?? '—'}{row.premiumCents == null + ? '—' + : amount(row.premiumCents) + ' / ' + row.cadence}
+
+ {#if !data.insurances.length}

+ Add policies to keep track of what is covered. +

{/if} +
+ {:else if tab === 'accounts'} +
+
+
+

Accounts

+

Opening balances plus every signed transaction.

+
+
+
+ + {#each data.accounts as row (row.id)}{/each} +
AccountOpening balanceCurrent balanceActions
{row.name}{amount(row.openingBalanceCents)}{amount(balances.get(row.id) ?? 0)}
+
+ {#if !data.accounts.length}

+ Add your first account or import a CSV to create them automatically. +

{/if} +
+ {/if} +
+ + (active = {})}> + {#if showActionError && form?.message}{/if} +
+

+ {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)} +

+ +
+ {#if mode === 'delete'} +

Delete {active.name}? This cannot be undone.

+
+ async ({ result, update }) => { + await update(); + showActionError = result.type === 'failure'; + if (result.type === 'success') dialog.close(); + }} + > + + +
+ {:else if mode === 'import'} +

+ Upload a CSV with date,account,description,amount 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. +

+
+ async ({ result, update }) => { + await update(); + showActionError = result.type === 'failure'; + if (result.type === 'success') dialog.close(); + }} + > + + +
+ {:else} +
+ async ({ result, update }) => { + await update({ reset: false }); + showActionError = result.type === 'failure'; + if (result.type === 'success') dialog.close(); + }} + > + {#if active.id}{/if} + {#if mode === 'account'} + + + {:else if mode === 'transaction' || mode === 'forecast'} + + + + + {:else if mode === 'recurring'} + + + + + {:else if mode === 'settings'} + + + + + {:else if mode === 'reading'} + + + + {:else if mode === 'wish'} + + + + {:else if mode === 'insurance'} + + + + + + {/if} + +
+ {/if} +
diff --git a/src/routes/projects/money-tracker/money-tracker.css b/src/routes/projects/money-tracker/money-tracker.css new file mode 100644 index 0000000..f06a6c3 --- /dev/null +++ b/src/routes/projects/money-tracker/money-tracker.css @@ -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%; + } +}