7 Commits

Author SHA1 Message Date
e4de2dcec3 Add privacy and terms pages
All checks were successful
Deploy production / deploy (push) Successful in 2m28s
2026-10-24 00:50:46 +00:00
00c1e8b1e7 money: quick-add bar, server-filtered transactions page, CSV import dedupe + Revolut statements 2026-10-24 00:50:46 +00:00
9a1b21ec39 money: Enable Banking client, bank-link consent CLI, bank-sync with reconcile, systemd timer (not installed) 2026-10-24 00:50:45 +00:00
b1ce715ec7 money: bank-sync schema (account scope/iban/external uid, transaction external id/source, bank_sessions) 2026-10-24 00:50:45 +00:00
064d41595f preflight: verify health grants and energy data
All checks were successful
Deploy production / deploy (push) Successful in 2m20s
2026-10-24 00:27:29 +00:00
fa2456b99e Merge branch 'energy-tracker' into energy-merge 2026-10-24 00:25:34 +00:00
0cf5d44062 Add energy tracker (/projects/energy)
Public, read-only page over the ned-collector energy schema: latest-hour
mix and per-source utilization, generation vs consumption with CO2
intensity (day/week/month/12 months/all years), NED forecast, gas storage
by year and per site, and gas-fired power share vs storage fill.
Incomplete hours (sources not summing to the NED total) are left out and
partial days/months/years are marked.
2026-10-23 23:11:27 +00:00
38 changed files with 15262 additions and 41 deletions

View File

@@ -39,3 +39,9 @@ HOUSE_WORKER_TOKEN=
HOUSE_DISCORD_WEBHOOK_URL=
HOUSE_NTFY_URL=
HOUSE_NTFY_TOKEN=
# Money tracker bank sync (scripts/bank-link.ts, scripts/bank-sync.ts; not read by the web app).
# Keep these in ~/.config/money-sync.env (mode 600), never in git.
ENABLEBANKING_APP_ID=
ENABLEBANKING_KEY_PATH=
ENABLEBANKING_REDIRECT_URL=https://stanrunge.dev/projects/money-tracker/bank-callback

View File

@@ -14,6 +14,7 @@
"drizzle-orm": "^1.0.0-rc.4",
"echarts": "^6.1.0",
"highlight.js": "^11.12.0",
"jose": "^6.2.10",
"marked": "^18.0.11",
"marked-highlight": "^2.2.4",
"mode-watcher": "^1.1.0",

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Money tracker bank sync (Enable Banking -> money.transactions)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
# A checkout of stan/website (main once merged). Not the live dev tree.
WorkingDirectory=%h/dev/website-money
# DB_URL (app role), ENABLEBANKING_APP_ID, ENABLEBANKING_KEY_PATH; mode 600, never committed.
EnvironmentFile=%h/.config/money-sync.env
ExecStart=/usr/bin/bun scripts/bank-sync.ts
TimeoutStartSec=600

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Money tracker bank sync, 4x per day (PSD2 allows ~4 unattended fetches/day)
[Timer]
OnCalendar=*-*-* 06,11,16,21:20:00 Europe/Amsterdam
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,65 @@
CREATE TABLE "money"."bank_sessions" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"provider" text DEFAULT 'enablebanking' NOT NULL,
"session_id" text NOT NULL CONSTRAINT "bank_sessions_session_id_key" UNIQUE,
"aspsp_name" text NOT NULL,
"aspsp_country" text NOT NULL,
"psu_type" text,
"status" text DEFAULT 'authorized' NOT NULL,
"valid_until" timestamp with time zone,
"accounts" jsonb DEFAULT '[]' NOT NULL,
"last_synced_at" timestamp with time zone,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "bank_sessions_status_check" CHECK (status IN ('authorized','expired','revoked','error'))
);
--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD COLUMN "scope" text DEFAULT 'personal' NOT NULL;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD COLUMN "owner" text;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD COLUMN "iban" text;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD COLUMN "external_uid" text;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD COLUMN "provider" text DEFAULT 'manual' NOT NULL;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD COLUMN "external_id" text;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD COLUMN "source" text DEFAULT 'manual' NOT NULL;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD COLUMN "booking_status" text;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD COLUMN "counterparty" text;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD COLUMN "category" text;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD COLUMN "raw" jsonb;--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD COLUMN "value_date" date;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_external_uid_key" UNIQUE("external_uid");--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_account_external_id_key" UNIQUE("account_id","external_id");--> statement-breakpoint
CREATE INDEX "bank_sessions_user_id_index" ON "money"."bank_sessions" ("user_id");--> statement-breakpoint
ALTER TABLE "money"."bank_sessions" ADD CONSTRAINT "bank_sessions_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_scope_check" CHECK (scope IN ('personal','joint'));--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD CONSTRAINT "accounts_provider_check" CHECK (provider IN ('manual','enablebanking'));--> statement-breakpoint
ALTER TABLE "money"."transactions" ADD CONSTRAINT "transactions_source_check" CHECK (source IN ('manual','csv','enablebanking'));--> statement-breakpoint
-- Hand-written below this line (not generated by drizzle-kit).
-- Backfill: the shared account with Juhee is the only joint one; everything else stays personal.
UPDATE "money"."accounts" SET "scope" = 'joint', "owner" = 'Stan & Juhee' WHERE lower("name") = 'joint';
--> statement-breakpoint
UPDATE "money"."accounts" SET "owner" = 'Stan' WHERE "scope" = 'personal' AND "owner" IS NULL;
--> statement-breakpoint
-- updated_at trigger, same as every other core table (07_triggers_meta_grants.sql).
DO $$
BEGIN
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON money.bank_sessions
FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
END IF;
END $$;
--> statement-breakpoint
-- Grants only run on prod (migrations run as the schema owner `personal`); the dev DB
-- (owned by personal_dev) carries no prod grants, matching db-refresh-dev.sh. Default
-- privileges on `money` already cover new tables; this is explicit for clarity.
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON money.bank_sessions TO personal_app;
GRANT USAGE, SELECT ON SEQUENCE money.bank_sessions_id_seq TO personal_app;
END IF;
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT SELECT ON money.bank_sessions TO personal_ro;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -92,6 +92,7 @@
"drizzle-orm": "^1.0.0-rc.4",
"echarts": "^6.1.0",
"highlight.js": "^11.12.0",
"jose": "^6.2.10",
"marked": "^18.0.11",
"marked-highlight": "^2.2.4",
"mode-watcher": "^1.1.0",

112
scripts/bank-link.ts Normal file
View File

@@ -0,0 +1,112 @@
// Link Revolut (personal + joint) to the money tracker through Enable Banking.
//
// bun --env-file=$HOME/.config/money-sync.env scripts/bank-link.ts interactive consent
// ... scripts/bank-link.ts --check verify key + app id only
// ... scripts/bank-link.ts --code '<redirect URL or code>' [--yes] finish a started consent
// ... scripts/bank-link.ts --map <uid|IBAN>=<Tracker account> [--map ...] override the mapping
// ... scripts/bank-link.ts --aspsp Revolut --country NL --user <auth user id>
//
// Needs ENABLEBANKING_APP_ID, ENABLEBANKING_KEY_PATH, DB_URL (and optionally
// ENABLEBANKING_REDIRECT_URL). Never prints the private key.
import { createInterface } from 'node:readline/promises';
import { randomUUID } from 'node:crypto';
import postgres from 'postgres';
import {
configFromEnv,
createEnableBankingClient,
parseAuthCode
} from '../src/lib/server/enable-banking';
import { planAccountMapping, saveSession, trackerUserId } from '../src/lib/server/bank-sync';
const args = process.argv.slice(2);
const flag = (name: string) => args.includes(name);
const value = (name: string) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const overrides: Record<string, string> = {};
args.forEach((arg, i) => {
if (arg !== '--map') return;
const [key, ...rest] = (args[i + 1] ?? '').split('=');
if (!key) return;
overrides[key] = rest.join('=');
overrides[key.replace(/\s/g, '').toUpperCase()] = rest.join('=');
});
const client = createEnableBankingClient(configFromEnv());
const aspsp = { name: value('--aspsp') ?? 'Revolut', country: value('--country') ?? 'NL' };
const redirectUrl =
process.env.ENABLEBANKING_REDIRECT_URL ??
'https://stanrunge.dev/projects/money-tracker/bank-callback';
const app = await client.application();
console.log(
`Enable Banking app "${app.name}" (${app.environment}, ${app.active ? 'active' : 'INACTIVE'}), services ${app.services.join(',')}`
);
const { aspsps } = await client.aspsps(aspsp.country);
const bank = aspsps.find((a) => a.name.toLowerCase() === aspsp.name.toLowerCase());
if (!bank) throw new Error(`${aspsp.name} (${aspsp.country}) is not available for this app`);
console.log(
`${bank.name} ${bank.country}: max consent ${Math.floor(bank.maximum_consent_validity / 86400)} days`
);
if (flag('--check')) process.exit(0);
const rl = createInterface({ input: process.stdin, output: process.stdout });
let pasted = value('--code');
if (!pasted) {
// Stay one hour under the bank's maximum so the API never rejects valid_until.
const validUntil = new Date(Date.now() + (bank.maximum_consent_validity - 3600) * 1000);
const auth = await client.startAuth({
aspsp: { name: bank.name, country: bank.country },
redirectUrl,
validUntil,
psuType: 'personal',
state: randomUUID()
});
console.log('\n1. Open this link and approve access in the Revolut app.');
console.log(' Tick BOTH your personal account and the joint account with Juhee:\n');
console.log(' ' + auth.url + '\n');
console.log(`2. You'll land on ${redirectUrl}?code=...`);
console.log(
' Copy the full address from the browser bar (or just the code) and paste it here.'
);
console.log(' The code expires quickly, so paste it within a few minutes.\n');
pasted = await rl.question('Redirect URL or code: ');
}
const parsed = parseAuthCode(pasted ?? '');
if (parsed.error) throw new Error(`Consent failed: ${parsed.error}`);
if (!parsed.code) throw new Error('No authorisation code found in what was pasted');
const session = await client.createSession(parsed.code);
console.log(
`\nSession authorised until ${session.access.valid_until}; ${session.accounts.length} account(s):`
);
const sql = postgres(process.env.DB_URL!, { max: 1, onnotice: () => {} });
try {
const userId = await trackerUserId(sql, value('--user'));
const tracker = await sql<
{ id: number; name: string; scope: string; iban: string | null; external_uid: string | null }[]
>`select id, name, scope, iban, external_uid from money.accounts where user_id = ${userId} order by id`;
const plan = planAccountMapping(session.accounts, tracker, overrides);
for (const { bank: account, tracker: target, reason } of plan) {
const iban = account.account_id?.iban ?? '(no IBAN)';
console.log(
` ${account.uid} ${iban} ${account.currency ?? ''} ${account.name ?? ''}${account.details ? ' / ' + account.details : ''}` +
`\n -> ${target ? target.name : 'not linked'} (${reason})`
);
}
const answer = flag('--yes') ? 'y' : await rl.question('\nSave this session and mapping? [y/N] ');
if (!/^y(es)?$/i.test(answer.trim())) {
console.log('Nothing saved. Re-run with --code is not possible (codes are single-use);');
console.log('start again and use --map <uid>=<Tracker account> to fix the mapping.');
process.exit(1);
}
await saveSession(sql, userId, session, plan);
console.log(
'Saved. Next: bun --env-file=$HOME/.config/money-sync.env scripts/bank-sync.ts --reconcile --dry-run'
);
} finally {
rl.close();
await sql.end();
}

48
scripts/bank-sync.ts Normal file
View File

@@ -0,0 +1,48 @@
// Pull booked bank transactions into money.transactions (Enable Banking).
//
// bun --env-file=$HOME/.config/money-sync.env scripts/bank-sync.ts [options]
// --dry-run fetch and report, write nothing
// --reconcile match against ALL hand-entered rows (account + amount + date ±1 day)
// and attach the bank id to them instead of inserting duplicates;
// run once after linking (with --dry-run first)
// --from YYYY-MM-DD override the start date (default: last synced date - 7 days, or the
// account's first transaction on the first run)
// --account <name> only this tracker account
//
// Dedupe: unique (account_id, external_id); re-running is always safe.
import postgres from 'postgres';
import { configFromEnv, createEnableBankingClient } from '../src/lib/server/enable-banking';
import { syncBankAccounts } from '../src/lib/server/bank-sync';
const args = process.argv.slice(2);
const value = (name: string) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const from = value('--from');
if (from && !/^\d{4}-\d{2}-\d{2}$/.test(from)) throw new Error('--from must be YYYY-MM-DD');
if (!process.env.DB_URL) throw new Error('DB_URL is not set');
const client = createEnableBankingClient(configFromEnv());
const sql = postgres(process.env.DB_URL, { max: 2, connect_timeout: 10, onnotice: () => {} });
const dryRun = args.includes('--dry-run');
try {
const results = await syncBankAccounts(sql, client, {
dryRun,
reconcile: args.includes('--reconcile'),
from,
accountName: value('--account')
});
for (const r of results) {
console.log(
`${r.account}: fetched ${r.fetched}, booked ${r.booked}, new ${r.inserted}, ` +
`matched to existing ${r.matched}, already synced ${r.alreadyKnown}` +
(r.balance ? `, bank balance ${r.balance}` : '') +
(r.error ? `\n ERROR ${r.error}` : '')
);
}
if (dryRun) console.log('(dry run: nothing written)');
if (results.some((r) => r.error)) process.exitCode = 1;
} finally {
await sql.end();
}

View File

@@ -107,6 +107,39 @@ try {
}
if (schema.app_has_ddl)
throw new Error('DB_URL must use the app role (personal_app), not the schema owner');
// Private health schema: the app role reads and writes it, nobody else (not even personal_ro).
const [health] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'health', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'health' and c.relkind = 'r'
`;
if (
Number(health.tables) !== 7 ||
Number(health.app_rw) !== 7 ||
Number(health.ro_read) !== 0 ||
health.public_usage
)
throw new Error(`health schema grants are wrong: ${JSON.stringify(health)}`);
// Energy tracker reads the ned-collector tables (SELECT only).
const [energy] = await client`
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
where granularity = 'Hour' and classification = 'Current'
and valid_from > now() - interval '7 days'
`;
if (!Number(energy.hours))
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
console.log(
`health: ${health.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
);
const [catalog] = await client`
select
(select count(*) from ref.pokemon) as pokemon,

View File

@@ -0,0 +1,104 @@
<script lang="ts" module>
export type ChartTheme = {
text: string;
muted: string;
border: string;
surface: string;
dark: boolean;
};
</script>
<script lang="ts">
import { onMount } from 'svelte';
import type { ECharts, EChartsCoreOption } from 'echarts/core';
// Generic ECharts wrapper for the energy tracker: same lazy-loading and theme handling as
// the CS price chart, but the caller builds the option from the current theme colours.
let {
option,
label,
height = 'min(52vh, 440px)'
}: {
option: (theme: ChartTheme) => EChartsCoreOption;
label: string;
height?: string;
} = $props();
let element: HTMLDivElement;
let chart: ECharts | undefined;
let ready = $state(false);
function update() {
if (!chart) return;
const style = getComputedStyle(document.documentElement);
const read = (name: string) => style.getPropertyValue(name).trim();
chart.setOption(
option({
text: read('--site-text'),
muted: read('--site-muted'),
border: read('--site-border'),
surface: read('--site-surface'),
dark: read('--site-bg') === '#000'
}),
true
);
}
onMount(() => {
let disposed = false;
let resize: ResizeObserver | undefined;
let theme: MutationObserver | undefined;
import('echarts/core').then(async (echarts) => {
const [
{ LineChart, BarChart, PieChart },
{ GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkAreaComponent },
{ CanvasRenderer }
] = await Promise.all([
import('echarts/charts'),
import('echarts/components'),
import('echarts/renderers')
]);
if (disposed) return;
echarts.use([
LineChart,
BarChart,
PieChart,
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
MarkAreaComponent,
CanvasRenderer
]);
chart = echarts.init(element, undefined, { renderer: 'canvas' });
ready = true;
update();
resize = new ResizeObserver(() => chart?.resize());
resize.observe(element);
theme = new MutationObserver(update);
theme.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
});
});
return () => {
disposed = true;
resize?.disconnect();
theme?.disconnect();
chart?.dispose();
};
});
$effect(() => {
// Re-render when the option builder (and therefore its inputs) changes.
void option;
if (ready) update();
});
</script>
<div class="chart" style:height bind:this={element} role="img" aria-label={label}></div>
<style>
.chart {
width: 100%;
min-height: 260px;
}
</style>

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { fillGaps, gaps, latestSnapshot, seasonalTime, toBuckets, type EnergyRow } from './data';
const hour = 3_600_000;
const rows: EnergyRow[] = [
{ t: 0, type: 'ElectricityMix', value: 100, ef: 0.2 },
{ t: 0, type: 'ElectricityLoad', value: 90 },
{ t: 0, type: 'Wind', value: 60, pct: 0.5 },
{ t: 0, type: 'FossilGasPower', value: 30, pct: 0.1 },
{ t: 0, type: 'Nuclear', value: 10, pct: 1 },
{ t: hour, type: 'ElectricityMix', value: 100 },
{ t: hour, type: 'Wind', value: 20 }
];
describe('energy buckets', () => {
it('flags buckets whose sources do not add up to the total', () => {
const buckets = toBuckets(rows);
expect(buckets.map((bucket) => bucket.incomplete)).toEqual([false, true]);
expect(buckets[0].sum).toBe(100);
expect(buckets[0].load).toBe(90);
});
it('summarises the latest complete bucket', () => {
const snapshot = latestSnapshot(toBuckets(rows))!;
expect(snapshot.t).toBe(0);
expect(snapshot.renewableShare).toBeCloseTo(0.6);
expect(snapshot.lowCarbonShare).toBeCloseTo(0.7);
expect(snapshot.fossilShare).toBeCloseTo(0.3);
expect(snapshot.sources[0]).toMatchObject({ type: 'Wind', share: 0.6, utilization: 0.5 });
});
it('finds and fills missing hours', () => {
expect(gaps([0, hour, 4 * hour])).toEqual([{ from: 2 * hour, to: 4 * hour, hours: 2 }]);
const filled = fillGaps(toBuckets([...rows, { t: 3 * hour, type: 'Wind', value: 1 }]), hour);
expect(filled.map((bucket) => 'missing' in bucket)).toEqual([false, false, true, false]);
});
it('maps dates onto one seasonal year', () => {
expect(seasonalTime('2023-02-28')).toBe(Date.UTC(2000, 1, 28));
expect(seasonalTime('2024-12-31')).toBe(Date.UTC(2000, 11, 31));
});
});

195
src/lib/energy/data.ts Normal file
View File

@@ -0,0 +1,195 @@
// Pure helpers for the energy tracker. Inputs come from the ned-collector tables in the
// `energy` schema (NED.nl electricity data and AGSI/GIE gas storage).
export type SourceType =
| 'Nuclear'
| 'WindOffshoreC'
| 'Wind'
| 'Solar'
| 'BiomassPower'
| 'WastePower'
| 'WKK Total'
| 'FossilGasPower'
| 'FossilHardCoal'
| 'OtherPower';
export type Source = {
type: SourceType;
label: string;
color: string;
renewable: boolean;
fossil: boolean;
};
// Stacking order, bottom to top: steady baseload first, weather-driven next, fossil on top.
export const SOURCES: readonly Source[] = [
{ type: 'Nuclear', label: 'Nuclear', color: '#a78bfa', renewable: false, fossil: false },
{
type: 'WindOffshoreC',
label: 'Wind offshore',
color: '#0284c7',
renewable: true,
fossil: false
},
{ type: 'Wind', label: 'Wind onshore', color: '#38bdf8', renewable: true, fossil: false },
{ type: 'Solar', label: 'Solar', color: '#facc15', renewable: true, fossil: false },
{ type: 'BiomassPower', label: 'Biomass', color: '#65a30d', renewable: true, fossil: false },
{ type: 'WastePower', label: 'Waste', color: '#a3a3a3', renewable: false, fossil: false },
{ type: 'WKK Total', label: 'CHP (WKK)', color: '#fdba74', renewable: false, fossil: true },
{ type: 'FossilGasPower', label: 'Fossil gas', color: '#f97316', renewable: false, fossil: true },
{ type: 'FossilHardCoal', label: 'Hard coal', color: '#57534e', renewable: false, fossil: true },
{ type: 'OtherPower', label: 'Other', color: '#94a3b8', renewable: false, fossil: false }
];
export const MIX = 'ElectricityMix';
export const LOAD = 'ElectricityLoad';
const sourceTypes = new Set<string>(SOURCES.map((source) => source.type));
/** One row per (time bucket, type). `value` is MWh for hours/days and GWh for months/years. */
export type EnergyRow = {
t: number;
type: string;
value: number;
pct?: number | null;
ef?: number | null;
hours?: number;
expected?: number;
};
export type Bucket = {
t: number;
mix: number | null;
load: number | null;
ef: number | null;
sources: Partial<Record<SourceType, { value: number; pct: number | null }>>;
sum: number;
hours?: number;
/** Hours in the period (23 or 25 on DST change days). */
expected?: number;
/** The source rows don't add up to the NED total, so the bucket is still being filled in. */
incomplete: boolean;
};
/** Group rows into buckets ordered by time. A bucket is incomplete when its sources sum to
* less than 98% (or more than 102%) of the published total. */
export function toBuckets(rows: readonly EnergyRow[]): Bucket[] {
const buckets = new Map<number, Bucket>();
for (const row of rows) {
let bucket = buckets.get(row.t);
if (!bucket) {
bucket = {
t: row.t,
mix: null,
load: null,
ef: null,
sources: {},
sum: 0,
incomplete: false
};
buckets.set(row.t, bucket);
}
if (row.hours !== undefined) bucket.hours = Math.max(bucket.hours ?? 0, row.hours);
if (row.expected !== undefined) bucket.expected = row.expected;
if (row.type === MIX) {
bucket.mix = row.value;
bucket.ef = row.ef ?? null;
} else if (row.type === LOAD) bucket.load = row.value;
else if (sourceTypes.has(row.type)) {
bucket.sources[row.type as SourceType] = { value: row.value, pct: row.pct ?? null };
bucket.sum += row.value;
}
}
const list = [...buckets.values()].sort((a, b) => a.t - b.t);
for (const bucket of list) {
bucket.incomplete =
bucket.mix === null ? bucket.sum === 0 : Math.abs(bucket.sum / bucket.mix - 1) > 0.02;
}
return list;
}
export type Snapshot = {
t: number;
total: number;
load: number | null;
ef: number | null;
renewableShare: number;
lowCarbonShare: number;
fossilShare: number;
sources: {
type: SourceType;
label: string;
color: string;
value: number;
share: number;
utilization: number | null;
}[];
};
/** The latest complete bucket, broken down by source. */
export function latestSnapshot(buckets: readonly Bucket[]): Snapshot | null {
const bucket = [...buckets]
.reverse()
.find((candidate) => !candidate.incomplete && candidate.sum > 0);
if (!bucket) return null;
const total = bucket.sum;
const share = (filter: (source: Source) => boolean) =>
SOURCES.filter(filter).reduce(
(sum, source) => sum + (bucket.sources[source.type]?.value ?? 0),
0
) / total;
return {
t: bucket.t,
total,
load: bucket.load,
ef: bucket.ef,
renewableShare: share((source) => source.renewable),
lowCarbonShare: share((source) => source.renewable || source.type === 'Nuclear'),
fossilShare: share((source) => source.fossil),
sources: SOURCES.map((source) => {
const entry = bucket.sources[source.type];
return {
type: source.type,
label: source.label,
color: source.color,
value: entry?.value ?? 0,
share: (entry?.value ?? 0) / total,
utilization: entry?.pct ?? null
};
}).sort((a, b) => b.value - a.value)
};
}
/** Insert empty buckets so charts show gaps where the collector missed data. */
export function fillGaps(
buckets: readonly Bucket[],
step: number
): (Bucket | { t: number; missing: true })[] {
const out: (Bucket | { t: number; missing: true })[] = [];
for (const bucket of buckets) {
const previous = out.at(-1);
if (previous)
for (let t = previous.t + step; t < bucket.t; t += step) out.push({ t, missing: true });
out.push(bucket);
}
return out;
}
/** Hours missing between consecutive buckets, as [from, to) ranges in epoch ms. */
export function gaps(
times: readonly number[],
step = 3_600_000
): { from: number; to: number; hours: number }[] {
const result = [];
for (let index = 1; index < times.length; index++) {
const missing = Math.round((times[index] - times[index - 1]) / step) - 1;
if (missing > 0)
result.push({ from: times[index - 1] + step, to: times[index], hours: missing });
}
return result;
}
/** Map a date onto leap year 2000 so different years can share one seasonal x axis. */
export function seasonalTime(day: string) {
const [, month, date] = day.split('-').map(Number);
return Date.UTC(2000, month - 1, date);
}

View File

@@ -0,0 +1,269 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { tick } from 'svelte';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import Plus from 'lucide-svelte/icons/plus';
import { descriptionSuggestions, type SuggestionSource } from './quick-add.js';
let {
accounts,
transactions,
today
}: {
accounts: { id: number; name: string }[];
transactions: SuggestionSource[];
today: string;
} = $props();
const storageKey = 'money-tracker:last-account';
const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
let kind = $state<'expense' | 'income'>('expense');
let description = $state('');
let amount = $state('');
let accountId = $state('');
let date = $state('');
let listOpen = $state(false);
let highlighted = $state(-1);
let message = $state('');
let failed = $state(false);
let saving = $state(false);
let descriptionInput = $state<HTMLInputElement | null>(null);
let amountInput = $state<HTMLInputElement | null>(null);
let suggestions = $derived(descriptionSuggestions(transactions, today));
let matches = $derived.by(() => {
const query = description.trim().toLowerCase();
if (!query) return [];
return suggestions
.filter((item) => item.key.includes(query) && item.key !== query)
.sort((a, b) => Number(b.key.startsWith(query)) - Number(a.key.startsWith(query)))
.slice(0, 6);
});
let signedAmount = $derived.by(() => {
const raw = amount.trim().replace(',', '.').replace(/^[-+]/, '');
return raw ? (kind === 'expense' ? '-' : '') + raw : '';
});
$effect.pre(() => {
if (!date) date = today;
if (!accountId && accounts.length) {
const remembered =
typeof localStorage === 'undefined' ? null : localStorage.getItem(storageKey);
accountId = String(
accounts.find((account) => String(account.id) === remembered)?.id ?? accounts[0].id
);
}
});
async function choose(index: number) {
const item = matches[index];
if (!item) return;
description = item.description;
amount = (Math.abs(item.amountCents) / 100).toFixed(2);
kind = item.amountCents < 0 ? 'expense' : 'income';
if (item.accountId && accounts.some((account) => account.id === item.accountId))
accountId = String(item.accountId);
listOpen = false;
highlighted = -1;
await tick();
amountInput?.select();
}
function keydown(event: KeyboardEvent) {
if (!matches.length) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
listOpen = true;
const step = event.key === 'ArrowDown' ? 1 : -1;
highlighted = (highlighted + step + matches.length) % matches.length;
} else if (event.key === 'Enter' && listOpen && highlighted >= 0) {
event.preventDefault();
choose(highlighted);
} else if (event.key === 'Escape') {
listOpen = false;
highlighted = -1;
} else if (event.key === 'Tab' && listOpen && highlighted >= 0) choose(highlighted);
}
</script>
<form
class="quick-add"
method="POST"
action="?/saveTransaction"
aria-label="Quick add transaction"
use:enhance={() => {
saving = true;
const saved = { description, signedAmount };
return async ({ result, update }) => {
saving = false;
if (result.type === 'success') {
localStorage.setItem(storageKey, accountId);
failed = false;
message = `Saved ${saved.description} · ${euro.format(Number(saved.signedAmount))}`;
description = '';
amount = '';
await update({ reset: false });
descriptionInput?.focus();
} else {
failed = true;
message =
result.type === 'failure' && typeof result.data?.message === 'string'
? result.data.message
: 'Could not save this transaction.';
}
};
}}
>
<div class="quick-kind" role="group" aria-label="Transaction type">
<Button
type="button"
variant={kind === 'expense' ? 'default' : 'ghost'}
aria-pressed={kind === 'expense'}
onclick={() => (kind = 'expense')}>Expense</Button
><Button
type="button"
variant={kind === 'income' ? 'default' : 'ghost'}
aria-pressed={kind === 'income'}
onclick={() => (kind = 'income')}>Income</Button
>
</div>
<div class="quick-description">
<label class="sr-only" for="quick-description">Description</label>
<Input
id="quick-description"
name="description"
autocomplete="off"
placeholder="What was it? e.g. Albert Heijn"
required
maxlength={500}
role="combobox"
aria-expanded={listOpen && matches.length > 0}
aria-controls="quick-suggestions"
aria-autocomplete="list"
aria-activedescendant={highlighted >= 0 ? `quick-suggestion-${highlighted}` : undefined}
bind:ref={descriptionInput}
bind:value={description}
oninput={() => {
listOpen = true;
highlighted = -1;
}}
onkeydown={keydown}
onfocus={() => (listOpen = true)}
onblur={() => setTimeout(() => (listOpen = false), 120)}
/>
{#if listOpen && matches.length}
<ul id="quick-suggestions" role="listbox" class="quick-suggestions">
{#each matches as item, index (item.key)}
<li
id={`quick-suggestion-${index}`}
role="option"
aria-selected={index === highlighted}
class:highlighted={index === highlighted}
onmousedown={(event) => {
event.preventDefault();
choose(index);
}}
>
<span>{item.description}</span>
<span class="muted"
>{euro.format(item.amountCents / 100)} · {accounts.find(
(a) => a.id === item.accountId
)?.name ?? ''}</span
>
</li>
{/each}
</ul>
{/if}
</div>
<label class="sr-only" for="quick-amount">Amount in euros</label>
<Input
id="quick-amount"
class="quick-amount"
inputmode="decimal"
placeholder="0.00"
required
pattern="[-+]?[0-9]+([.,][0-9]{(1, 2)})?"
bind:ref={amountInput}
bind:value={amount}
/>
<input type="hidden" name="amount" value={signedAmount} />
<label class="sr-only" for="quick-account">Account</label>
<NativeSelect id="quick-account" name="accountId" bind:value={accountId} required
>{#each accounts as account (account.id)}<option value={String(account.id)}
>{account.name}</option
>{/each}</NativeSelect
>
<label class="sr-only" for="quick-date">Date</label>
<Input id="quick-date" class="quick-date" type="date" name="date" bind:value={date} required />
<Button type="submit" disabled={saving || !accounts.length}><Plus size={16} /> Add</Button>
{#if message}<p class="quick-message" class:form-error={failed} role="status">{message}</p>{/if}
</form>
<style>
.quick-add {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 0 0 20px;
padding: 12px;
border: 1px solid var(--site-border);
border-radius: 10px;
}
.quick-kind {
display: inline-flex;
gap: 2px;
padding: 2px;
border: 1px solid var(--site-border);
border-radius: 8px;
}
.quick-description {
position: relative;
flex: 1 1 220px;
}
.quick-add :global(.quick-amount) {
width: 110px;
text-align: right;
}
.quick-add :global(.quick-date) {
width: 150px;
}
.quick-suggestions {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
right: 0;
margin: 0;
padding: 4px;
list-style: none;
background: var(--site-bg);
border: 1px solid var(--site-border);
border-radius: 8px;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
}
.quick-suggestions li {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.quick-suggestions li.highlighted,
.quick-suggestions li:hover {
background: var(--site-border);
}
.quick-message {
flex-basis: 100%;
margin: 0;
font-size: 13px;
}
.muted {
opacity: 0.65;
}
</style>

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { descriptionSuggestions } from './quick-add';
describe('quick-add suggestions', () => {
it('ranks by use and fills the usual amount and latest account', () => {
const rows = [
{ description: 'PLUS', amountCents: -1200, accountId: 1, date: '2026-10-01' },
{ description: 'plus', amountCents: -1200, accountId: 1, date: '2026-10-03' },
{ description: 'Plus', amountCents: -800, accountId: 4, date: '2026-10-05' },
{ description: 'From Juhee', amountCents: 5000, accountId: 4, date: '2026-10-02' },
{ description: 'Future', amountCents: -1, accountId: 1, date: '2026-12-01' }
];
const [first, second, ...rest] = descriptionSuggestions(rows, '2026-10-24');
expect(first).toMatchObject({
key: 'plus',
description: 'Plus',
amountCents: -1200,
accountId: 4,
count: 3
});
expect(second).toMatchObject({ description: 'From Juhee', amountCents: 5000 });
expect(rest).toEqual([]);
});
});

View File

@@ -0,0 +1,73 @@
export type SuggestionSource = {
description: string;
amountCents: number;
accountId: number | null;
date: string;
};
export type Suggestion = {
key: string;
description: string;
/** Most common amount for this description (latest wins ties). */
amountCents: number;
/** Account used most recently with this description. */
accountId: number | null;
count: number;
lastDate: string;
};
/** Past descriptions ranked by use, each with its usual amount and latest account. */
export function descriptionSuggestions(rows: SuggestionSource[], today: string): Suggestion[] {
const groups = new Map<
string,
{
description: string;
amounts: Map<number, number>;
accountId: number | null;
count: number;
lastDate: string;
lastAmount: number;
}
>();
for (const row of rows) {
if (row.date > today) continue;
const key = row.description.trim().toLowerCase();
if (!key) continue;
const group = groups.get(key) ?? {
description: row.description.trim(),
amounts: new Map(),
accountId: row.accountId,
count: 0,
lastDate: '',
lastAmount: row.amountCents
};
group.count++;
group.amounts.set(row.amountCents, (group.amounts.get(row.amountCents) ?? 0) + 1);
if (row.date >= group.lastDate) {
group.lastDate = row.date;
group.description = row.description.trim();
group.accountId = row.accountId;
group.lastAmount = row.amountCents;
}
groups.set(key, group);
}
return [...groups.entries()]
.map(([key, group]) => {
let amountCents = group.lastAmount;
let best = group.amounts.get(amountCents) ?? 0;
for (const [value, uses] of group.amounts)
if (uses > best) {
best = uses;
amountCents = value;
}
return {
key,
description: group.description,
amountCents,
accountId: group.accountId,
count: group.count,
lastDate: group.lastDate
};
})
.sort((a, b) => b.count - a.count || b.lastDate.localeCompare(a.lastDate));
}

View File

@@ -58,6 +58,12 @@ export const trackers = [
href: '/projects/cs-prices',
external: false
},
{
name: 'energy',
subtitle: 'Dutch electricity mix, utilization and gas storage',
href: '/projects/energy',
external: false
},
{
name: 'animaldex',
subtitle: 'add neighborhood pets to the world map',

View File

@@ -38,7 +38,7 @@ const publicPages: Record<string, { title: string; description: string }> = {
'/trackers': {
title: 'Trackers',
description:
'Stan Runge’s trackers: games, money, houses, Counter-Strike prices, Animaldex and lists.'
'Stan Runge’s trackers: games, money, houses, Counter-Strike prices, Dutch energy, Animaldex and lists.'
},
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
'/projects/game-tracker': {
@@ -69,6 +69,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
description:
'Compare historical Counter-Strike market prices by listing, weapon, skin, wear, and other item dimensions.'
},
'/projects/energy': {
title: 'Energy',
description:
'Dutch electricity generation mix, source utilization, consumption trends, CO₂ intensity and gas storage, from NED.nl data.'
},
'/projects/derby': {
title: 'Derby Lens',
description: 'Estimate horse racing odds and compare them with market prices.'

View File

@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest';
import { generateKeyPairSync } from 'node:crypto';
import { decodeProtectedHeader, jwtVerify } from 'jose';
import {
amountToCents,
externalIdFor,
findMatch,
mapBankTransaction,
planAccountMapping
} from './bank-sync';
import { createEnableBankingClient, parseAuthCode, type BankTransaction } from './enable-banking';
const tx = (overrides: Partial<BankTransaction> = {}): BankTransaction => ({
entry_reference: 'ref-1',
transaction_amount: { currency: 'EUR', amount: '12.34' },
credit_debit_indicator: 'DBIT',
status: 'BOOK',
booking_date: '2026-10-20',
value_date: '2026-10-19',
creditor: { name: 'Albert Heijn' },
remittance_information: ['AH 1234 Gouda'],
...overrides
});
describe('bank transaction mapping', () => {
it('parses decimal amounts exactly', () => {
expect(amountToCents('12.34')).toBe(1234);
expect(amountToCents('-0.5')).toBe(-50);
expect(amountToCents('100')).toBe(10000);
expect(amountToCents('1.005')).toBe(101);
});
it('signs debits negative and uses the counterparty as description', () => {
const row = mapBankTransaction(tx())!;
expect(row).toMatchObject({
externalId: 'ref-1',
date: '2026-10-20',
valueDate: '2026-10-19',
amountCents: -1234,
description: 'Albert Heijn',
counterparty: 'Albert Heijn',
bookingStatus: 'BOOK'
});
const credit = mapBankTransaction(
tx({ credit_debit_indicator: 'CRDT', creditor: null, debtor: { name: 'Juhee' } })
)!;
expect(credit.amountCents).toBe(1234);
expect(credit.description).toBe('Juhee');
});
it('skips pending transactions', () => {
expect(mapBankTransaction(tx({ status: 'PDNG' }))).toBeNull();
});
it('falls back to remittance info and to a stable content hash', () => {
const anonymous = tx({ entry_reference: null, creditor: null });
const row = mapBankTransaction(anonymous)!;
expect(row.description).toBe('AH 1234 Gouda');
expect(row.externalId).toMatch(/^h:[0-9a-f]{32}$/);
expect(externalIdFor(anonymous)).toBe(row.externalId);
expect(
externalIdFor({ ...anonymous, transaction_amount: { currency: 'EUR', amount: '1.00' } })
).not.toBe(row.externalId);
});
});
describe('reconcile matching', () => {
const rows = [
{ id: 1, date: '2026-10-18', amountCents: -1234, description: 'Groceries' },
{ id: 2, date: '2026-10-21', amountCents: -1234, description: 'Albert Heijn' },
{ id: 3, date: '2026-10-20', amountCents: -999, description: 'Albert Heijn' }
];
it('matches same amount within one day, preferring description overlap', () => {
const m = findMatch(
{ date: '2026-10-20', amountCents: -1234, description: 'Albert Heijn' },
rows,
new Set()
);
expect(m?.id).toBe(2);
});
it('never reuses a row and ignores rows more than a day away', () => {
expect(
findMatch({ date: '2026-10-20', amountCents: -1234, description: 'x' }, rows, new Set([2]))
).toBeNull();
});
});
describe('account mapping', () => {
const tracker = [
{ id: 1, name: 'Revolut', scope: 'personal', iban: null, external_uid: null },
{ id: 4, name: 'Joint', scope: 'joint', iban: null, external_uid: null }
];
it('maps the joint account and the personal EUR account', () => {
const plan = planAccountMapping(
[
{ uid: 'a', account_id: { iban: 'LT1' }, name: 'Stan Runge', currency: 'EUR' },
{ uid: 'b', account_id: { iban: 'LT2' }, name: 'Stan Runge & Juhee', currency: 'EUR' },
{ uid: 'c', account_id: { iban: 'LT3' }, name: 'Stan Runge', currency: 'USD' }
],
tracker
);
expect(plan.map((p) => [p.bank.uid, p.tracker?.name ?? null])).toEqual([
['b', 'Joint'],
['a', 'Revolut'],
['c', null]
]);
});
it('honours overrides and existing IBANs', () => {
const plan = planAccountMapping(
[
{ uid: 'a', account_id: { iban: 'LT1' }, name: 'X', currency: 'EUR' },
{ uid: 'b', account_id: { iban: 'LT2' }, name: 'Y', currency: 'EUR' }
],
[{ ...tracker[0], iban: 'LT2' }, tracker[1]],
{ a: 'Joint' }
);
expect(plan.map((p) => [p.bank.uid, p.tracker?.name])).toEqual([
['a', 'Joint'],
['b', 'Revolut']
]);
});
});
describe('enable banking client', () => {
it('parses codes from redirect URLs, query strings and bare codes', () => {
expect(
parseAuthCode(
'https://stanrunge.dev/projects/money-tracker/bank-callback?state=s&code=abc-123'
)
).toEqual({
code: 'abc-123',
state: 's',
error: undefined
});
expect(parseAuthCode('abc-123').code).toBe('abc-123');
expect(parseAuthCode('?error=access_denied&error_description=Cancelled').error).toBe(
'access_denied: Cancelled'
);
});
it('signs RS256 JWTs with the app id as kid and pages through transactions', async () => {
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const calls: string[] = [];
const fakeFetch = (async (url: string, init: RequestInit) => {
calls.push(url);
const auth = String((init.headers as Record<string, string>).Authorization).slice(7);
expect(decodeProtectedHeader(auth)).toMatchObject({
alg: 'RS256',
kid: 'app-id',
typ: 'JWT'
});
const { payload } = await jwtVerify(auth, publicKey, {
issuer: 'enablebanking.com',
audience: 'api.enablebanking.com'
});
expect(payload.exp! - payload.iat!).toBeLessThanOrEqual(86400);
const page = new URL(url).searchParams.get('continuation_key');
return new Response(
JSON.stringify(
page
? { transactions: [tx({ entry_reference: 'r2' })] }
: { transactions: [tx()], continuation_key: 'next' }
)
);
}) as unknown as typeof fetch;
const client = createEnableBankingClient({
appId: 'app-id',
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
fetch: fakeFetch
});
const all = await client.transactions('uid-1', { dateFrom: '2026-01-01' });
expect(all.map((t) => t.entry_reference)).toEqual(['ref-1', 'r2']);
expect(calls[1]).toContain('continuation_key=next');
expect(calls[0]).toContain('date_from=2026-01-01');
});
});

411
src/lib/server/bank-sync.ts Normal file
View File

@@ -0,0 +1,411 @@
// Bank -> money.transactions sync (Enable Banking). Pure mapping/matching helpers are
// exported for unit tests; the DB functions take a postgres.js `Sql` so they run the
// same from the bun scripts (scripts/bank-link.ts, scripts/bank-sync.ts).
import { createHash } from 'node:crypto';
import type { Sql } from 'postgres';
import type {
BankAccount,
BankTransaction,
EnableBankingClient,
SessionResponse
} from './enable-banking';
import { EnableBankingError } from './enable-banking';
export type MappedTransaction = {
externalId: string;
date: string;
valueDate: string | null;
description: string;
counterparty: string | null;
amountCents: number;
bookingStatus: string;
raw: BankTransaction;
};
export function amountToCents(amount: string): number {
const raw = amount.trim();
if (!/^-?\d+(?:\.\d+)?$/.test(raw)) throw new Error(`Invalid amount ${amount}`);
const negative = raw.startsWith('-');
const [whole, fraction = ''] = raw.replace('-', '').split('.');
const cents = Number(whole) * 100 + Number((fraction + '00').slice(0, 2));
const rounded = Number(fraction.slice(2, 3) || '0') >= 5 ? cents + 1 : cents;
return negative ? -rounded : rounded;
}
/** Stable id for a bank transaction: the bank's own reference, else a content hash. */
export function externalIdFor(tx: BankTransaction): string {
const ref = tx.entry_reference?.trim() || tx.transaction_id?.trim();
if (ref) return ref;
const fingerprint = [
tx.booking_date ?? tx.transaction_date ?? tx.value_date ?? '',
tx.credit_debit_indicator,
tx.transaction_amount.amount,
tx.transaction_amount.currency,
tx.creditor?.name ?? '',
tx.debtor?.name ?? '',
(tx.remittance_information ?? []).join('|')
].join('\u001f');
return 'h:' + createHash('sha256').update(fingerprint).digest('hex').slice(0, 32);
}
export function isBooked(tx: BankTransaction) {
return tx.status ? tx.status === 'BOOK' : Boolean(tx.booking_date);
}
/** Maps one booked bank transaction to a tracker row, or null for pending ones. */
export function mapBankTransaction(tx: BankTransaction): MappedTransaction | null {
if (!isBooked(tx)) return null;
const date = tx.booking_date ?? tx.transaction_date ?? tx.value_date;
if (!date) return null;
const magnitude = Math.abs(amountToCents(tx.transaction_amount.amount));
const amountCents = tx.credit_debit_indicator === 'DBIT' ? -magnitude : magnitude;
const counterparty =
(tx.credit_debit_indicator === 'DBIT' ? tx.creditor?.name : tx.debtor?.name)?.trim() || null;
const remittance = (tx.remittance_information ?? []).map((line) => line.trim()).filter(Boolean);
const description = (
counterparty ||
remittance.join(' ') ||
tx.bank_transaction_code?.description ||
tx.note ||
(amountCents < 0 ? 'Card payment' : 'Incoming transfer')
).slice(0, 500);
return {
externalId: externalIdFor(tx),
date: date.slice(0, 10),
valueDate: tx.value_date?.slice(0, 10) ?? null,
description,
counterparty,
amountCents,
bookingStatus: tx.status ?? 'BOOK',
raw: tx
};
}
function dayDiff(a: string, b: string) {
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
}
export type ExistingRow = { id: number; date: string; amountCents: number; description: string };
/**
* Finds the hand-entered row a bank transaction corresponds to: same amount, date within
* ±1 day, not matched yet. Prefers the closest date, then a description overlap.
*/
export function findMatch(
tx: Pick<MappedTransaction, 'date' | 'amountCents' | 'description'>,
candidates: ExistingRow[],
used: Set<number>
): ExistingRow | null {
const words = new Set(
tx.description
.toLowerCase()
.split(/\W+/)
.filter((w) => w.length > 2)
);
let best: { row: ExistingRow; score: number } | null = null;
for (const row of candidates) {
if (used.has(row.id) || row.amountCents !== tx.amountCents) continue;
const diff = dayDiff(row.date, tx.date);
if (diff > 1) continue;
const overlap = row.description
.toLowerCase()
.split(/\W+/)
.some((w) => words.has(w));
const score = diff * 10 - (overlap ? 1 : 0) + row.id / 1e9;
if (!best || score < best.score) best = { row, score };
}
return best?.row ?? null;
}
export function isoDate(date: Date) {
return date.toISOString().slice(0, 10);
}
export function addDays(date: string, days: number) {
return isoDate(new Date(Date.parse(date + 'T00:00:00Z') + days * 86_400_000));
}
/** Heuristic: is this bank account the joint one shared with Juhee? */
export function looksJoint(account: BankAccount) {
const text = [account.name, account.details, account.product].filter(Boolean).join(' ');
return /joint|gezamenlijk|shared|\bjuhee\b|\s&\s|\sand\s|,/i.test(text);
}
type TrackerAccount = {
id: number;
name: string;
scope: string;
iban: string | null;
external_uid: string | null;
};
/**
* Decides which tracker account each bank account feeds. Order: explicit override
* (uid/IBAN -> tracker account name), same IBAN, joint heuristic -> the joint-scoped
* account, first remaining EUR current account -> "Revolut". Others are skipped.
*/
export function planAccountMapping(
bankAccounts: BankAccount[],
tracker: TrackerAccount[],
overrides: Record<string, string> = {}
) {
const byName = new Map(tracker.map((a) => [a.name.toLowerCase(), a]));
const used = new Set<number>();
const plan: { bank: BankAccount; tracker: TrackerAccount | null; reason: string }[] = [];
const take = (bank: BankAccount, account: TrackerAccount | undefined | null, reason: string) => {
if (account && !used.has(account.id)) {
used.add(account.id);
plan.push({ bank, tracker: account, reason });
return true;
}
return false;
};
const pending: BankAccount[] = [];
for (const bank of bankAccounts) {
const iban = bank.account_id?.iban?.replace(/\s/g, '').toUpperCase();
const override = overrides[bank.uid ?? ''] ?? (iban ? overrides[iban] : undefined);
if (override !== undefined) {
if (override === '' || override === '-')
plan.push({ bank, tracker: null, reason: 'skipped (override)' });
else if (!take(bank, byName.get(override.toLowerCase()), 'override'))
plan.push({ bank, tracker: null, reason: `override target "${override}" not found/used` });
continue;
}
if (
iban &&
take(
bank,
tracker.find((a) => a.iban?.replace(/\s/g, '').toUpperCase() === iban),
'same IBAN'
)
)
continue;
pending.push(bank);
}
const eur = (bank: BankAccount) => !bank.currency || bank.currency === 'EUR';
// Joint first, so a personal-looking account listed earlier can't take "Revolut" from it.
const rest = pending.filter(
(bank) =>
!(
eur(bank) &&
looksJoint(bank) &&
take(
bank,
tracker.find((a) => a.scope === 'joint'),
'joint account'
)
)
);
for (const bank of rest) {
if (
eur(bank) &&
bank.cash_account_type !== 'SVGS' &&
take(bank, byName.get('revolut'), 'personal EUR account')
)
continue;
plan.push({ bank, tracker: null, reason: 'no match (use --map uid=Account)' });
}
return plan;
}
export async function trackerUserId(sql: Sql, explicit?: string) {
if (explicit) return explicit;
const rows = await sql<{ user_id: string }[]>`select distinct user_id from money.accounts`;
if (rows.length !== 1)
throw new Error(`Expected one money-tracker user, found ${rows.length}; pass --user <id>`);
return rows[0].user_id;
}
/** Stores a freshly authorised session and links bank accounts to tracker accounts. */
export async function saveSession(
sql: Sql,
userId: string,
session: SessionResponse,
plan: ReturnType<typeof planAccountMapping>
) {
const accounts = session.accounts.map((a) => ({
uid: a.uid ?? null,
iban: a.account_id?.iban ?? null,
name: a.name ?? null,
details: a.details ?? null,
product: a.product ?? null,
currency: a.currency ?? null,
cash_account_type: a.cash_account_type ?? null,
identification_hash: a.identification_hash ?? null
}));
await sql.begin(async (tx) => {
await tx`
insert into money.bank_sessions
(user_id, provider, session_id, aspsp_name, aspsp_country, psu_type, status, valid_until, accounts)
values (${userId}, 'enablebanking', ${session.session_id}, ${session.aspsp.name},
${session.aspsp.country}, ${session.psu_type}, 'authorized', ${session.access.valid_until},
${tx.json(accounts)})
on conflict (session_id) do update set status = 'authorized', valid_until = excluded.valid_until,
accounts = excluded.accounts, last_error = null`;
for (const { bank, tracker } of plan) {
if (!tracker || !bank.uid) continue;
// Account uids are per session: free the uid from any older mapping first.
await tx`update money.accounts set external_uid = null where external_uid = ${bank.uid} and id <> ${tracker.id}`;
await tx`
update money.accounts
set external_uid = ${bank.uid}, provider = 'enablebanking',
iban = coalesce(${bank.account_id?.iban ?? null}, iban)
where id = ${tracker.id} and user_id = ${userId}`;
}
});
}
export type SyncOptions = {
dryRun?: boolean;
/** Match against the whole history of hand-entered rows (one-time), not just the window. */
reconcile?: boolean;
from?: string;
accountName?: string;
log?: (line: string) => void;
today?: string;
};
export type AccountSyncResult = {
account: string;
fetched: number;
booked: number;
inserted: number;
matched: number;
alreadyKnown: number;
balance?: string;
error?: string;
};
export async function syncBankAccounts(
sql: Sql,
client: EnableBankingClient,
options: SyncOptions = {}
) {
const log = options.log ?? console.log;
const today = options.today ?? isoDate(new Date());
const accounts = await sql<
{ id: number; user_id: string; name: string; external_uid: string }[]
>`select id, user_id, name, external_uid from money.accounts
where provider = 'enablebanking' and external_uid is not null order by id`;
const selected = options.accountName
? accounts.filter((a) => a.name.toLowerCase() === options.accountName!.toLowerCase())
: accounts;
if (!selected.length) {
log('No linked bank accounts. Run scripts/bank-link.ts first.');
return [];
}
const results: AccountSyncResult[] = [];
for (const account of selected) {
const result: AccountSyncResult = {
account: account.name,
fetched: 0,
booked: 0,
inserted: 0,
matched: 0,
alreadyKnown: 0
};
results.push(result);
try {
const [{ last }] = await sql<{ last: string | null }[]>`
select max(date)::text as last from money.transactions
where account_id = ${account.id} and source = 'enablebanking'`;
const [{ first }] = await sql<{ first: string | null }[]>`
select min(date)::text as first from money.transactions where account_id = ${account.id}`;
const dateFrom = options.from ?? (last ? addDays(last, -7) : (first ?? addDays(today, -89)));
let raw: BankTransaction[];
try {
raw = await client.transactions(account.external_uid, { dateFrom });
} catch (error) {
// Many banks only serve ~90 days of history after the first hour of consent.
if (error instanceof EnableBankingError && error.code === 'WRONG_TRANSACTIONS_PERIOD') {
log(`${account.name}: ${dateFrom} is too far back; retrying from 89 days ago`);
raw = await client.transactions(account.external_uid, { dateFrom: addDays(today, -89) });
} else throw error;
}
result.fetched = raw.length;
const mapped = raw.map(mapBankTransaction).filter((t): t is MappedTransaction => t !== null);
result.booked = mapped.length;
if (!mapped.length) continue;
const ids = mapped.map((t) => t.externalId);
const known = new Set(
(
await sql<{ external_id: string }[]>`
select external_id from money.transactions
where account_id = ${account.id} and external_id = any(${ids})`
).map((r) => r.external_id)
);
const minDate = mapped.reduce((m, t) => (t.date < m ? t.date : m), mapped[0].date);
const candidates = await sql<ExistingRow[]>`
select id, date::text as date, amount_cents as "amountCents", description
from money.transactions
where account_id = ${account.id} and external_id is null
and date >= ${options.reconcile ? '0001-01-01' : addDays(minDate, -1)}`;
const used = new Set<number>();
const inserts: MappedTransaction[] = [];
const updates: { id: number; tx: MappedTransaction }[] = [];
// Oldest first so matches are claimed in chronological order.
for (const tx of [...mapped].sort((a, b) => a.date.localeCompare(b.date))) {
if (known.has(tx.externalId)) {
result.alreadyKnown++;
continue;
}
known.add(tx.externalId);
const match = findMatch(tx, candidates, used);
if (match) {
used.add(match.id);
updates.push({ id: match.id, tx });
} else inserts.push(tx);
}
result.matched = updates.length;
result.inserted = inserts.length;
if (!options.dryRun) {
await sql.begin(async (q) => {
for (const { id, tx } of updates) {
// Keep Stan's own description/category; attach the bank identity.
await q`
update money.transactions set external_id = ${tx.externalId},
counterparty = coalesce(counterparty, ${tx.counterparty}),
booking_status = ${tx.bookingStatus}, value_date = ${tx.valueDate},
raw = ${q.json(tx.raw as never)}
where id = ${id} and external_id is null`;
}
for (const tx of inserts) {
await q`
insert into money.transactions
(user_id, account_id, date, description, amount_cents, external_id, source,
booking_status, counterparty, raw, value_date)
values (${account.user_id}, ${account.id}, ${tx.date}, ${tx.description},
${tx.amountCents}, ${tx.externalId}, 'enablebanking', ${tx.bookingStatus},
${tx.counterparty}, ${q.json(tx.raw as never)}, ${tx.valueDate})
on conflict (account_id, external_id) do nothing`;
}
});
}
try {
const { balances } = await client.balances(account.external_uid);
const preferred =
balances.find((b) => b.balance_type === 'CLBD') ??
balances.find((b) => b.balance_type === 'ITAV') ??
balances[0];
if (preferred)
result.balance = `${preferred.balance_amount.amount} ${preferred.balance_amount.currency} (${preferred.balance_type})`;
} catch {
/* balances are informational only */
}
} catch (error) {
result.error = error instanceof Error ? error.message : String(error);
}
}
if (!options.dryRun) {
const failed = results.filter((r) => r.error).map((r) => `${r.account}: ${r.error}`);
const expired = failed.some((f) => /EXPIRED_SESSION|REVOKED_SESSION|CLOSED_SESSION/.test(f));
await sql`
update money.bank_sessions set last_synced_at = now(),
last_error = ${failed.length ? failed.join('\n').slice(0, 2000) : null},
status = case when ${expired} then 'expired'
when valid_until is not null and valid_until < now() then 'expired' else status end
where status = 'authorized'`;
}
return results;
}

View File

@@ -1,7 +1,17 @@
import { boolean, check, date, index, integer, numeric, text, unique } from 'drizzle-orm/pg-core';
import {
boolean,
check,
date,
index,
integer,
jsonb,
numeric,
text,
unique
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { moneySchema } from './schemas';
import { id, timestamps } from './helpers';
import { id, timestamps, timestamptz } from './helpers';
import { user } from './auth';
export const moneyAccounts = moneySchema.table(
@@ -15,12 +25,22 @@ export const moneyAccounts = moneySchema.table(
openingBalanceCents: integer('opening_balance_cents').notNull().default(0),
kind: text('kind').notNull().default('spendable'),
color: text('color'),
// Household scope: Stan's own money vs. the joint account with Juhee.
scope: text('scope').notNull().default('personal'),
owner: text('owner'),
iban: text('iban'),
// Bank-side account identifier (Enable Banking account uid) for synced accounts.
externalUid: text('external_uid'),
provider: text('provider').notNull().default('manual'),
...timestamps
},
(table) => [
unique().on(table.userId, table.name),
unique('accounts_external_uid_key').on(table.externalUid),
index().on(table.userId),
check('accounts_kind_check', sql`kind IN ('spendable','investment')`)
check('accounts_kind_check', sql`kind IN ('spendable','investment')`),
check('accounts_scope_check', sql`scope IN ('personal','joint')`),
check('accounts_provider_check', sql`provider IN ('manual','enablebanking')`)
]
);
@@ -37,9 +57,53 @@ export const moneyTransactions = moneySchema.table(
date: date('date', { mode: 'string' }).notNull(),
description: text('description').notNull(),
amountCents: integer('amount_cents').notNull(),
// Bank transaction id (Enable Banking entry_reference / transaction_id, or a
// content hash for CSV imports). NULLs are distinct, so manual rows never clash.
externalId: text('external_id'),
source: text('source').notNull().default('manual'),
bookingStatus: text('booking_status'),
counterparty: text('counterparty'),
category: text('category'),
raw: jsonb('raw'),
valueDate: date('value_date', { mode: 'string' }),
...timestamps
},
(table) => [index().on(table.userId, table.date, table.id), index().on(table.accountId)]
(table) => [
index().on(table.userId, table.date, table.id),
index().on(table.accountId),
unique('transactions_account_external_id_key').on(table.accountId, table.externalId),
check('transactions_source_check', sql`source IN ('manual','csv','enablebanking')`)
]
);
// Open-banking consent sessions (Enable Banking). One row per authorised session;
// `accounts` keeps the bank-side account list returned when the session was created.
export const moneyBankSessions = moneySchema.table(
'bank_sessions',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
provider: text('provider').notNull().default('enablebanking'),
sessionId: text('session_id').notNull(),
aspspName: text('aspsp_name').notNull(),
aspspCountry: text('aspsp_country').notNull(),
psuType: text('psu_type'),
status: text('status').notNull().default('authorized'),
validUntil: timestamptz('valid_until'),
accounts: jsonb('accounts')
.notNull()
.default(sql`'[]'::jsonb`),
lastSyncedAt: timestamptz('last_synced_at'),
lastError: text('last_error'),
...timestamps
},
(table) => [
unique('bank_sessions_session_id_key').on(table.sessionId),
index().on(table.userId),
check('bank_sessions_status_check', sql`status IN ('authorized','expired','revoked','error')`)
]
);
export const moneyRecurring = moneySchema.table(

View File

@@ -0,0 +1,260 @@
// Minimal Enable Banking (https://enablebanking.com/docs/api/reference/) client for
// account information (AIS): consent (POST /auth + POST /sessions), balances and
// transactions. Every request carries a short-lived RS256 JWT signed with the
// application's private key; the key never leaves this process.
//
// Framework-free on purpose: used by the SvelteKit callback route and by the
// bun scripts in scripts/ (bank-link.ts, bank-sync.ts), which don't load $app/env.
import { readFileSync } from 'node:fs';
import { createPrivateKey, randomUUID, type KeyObject } from 'node:crypto';
import { SignJWT } from 'jose';
export const ENABLE_BANKING_API = 'https://api.enablebanking.com';
export type EnableBankingConfig = {
appId: string;
/** PEM private key (PKCS#1 or PKCS#8). Prefer keyPath so the key isn't in env. */
privateKeyPem?: string;
keyPath?: string;
baseUrl?: string;
fetch?: typeof fetch;
};
export type Amount = { currency: string; amount: string };
export type BankAccount = {
uid?: string;
account_id?: { iban?: string; other?: { identification: string; scheme_name: string } };
name?: string;
details?: string;
product?: string;
currency?: string;
cash_account_type?: string;
usage?: string;
identification_hash?: string;
};
export type BankTransaction = {
entry_reference?: string | null;
transaction_id?: string | null;
transaction_amount: Amount;
credit_debit_indicator: 'CRDT' | 'DBIT';
status?: string;
booking_date?: string | null;
value_date?: string | null;
transaction_date?: string | null;
creditor?: { name?: string | null } | null;
debtor?: { name?: string | null } | null;
creditor_account?: { iban?: string | null } | null;
debtor_account?: { iban?: string | null } | null;
remittance_information?: string[] | null;
bank_transaction_code?: { description?: string | null } | null;
merchant_category_code?: string | null;
note?: string | null;
[key: string]: unknown;
};
export type Balance = {
name?: string;
balance_amount: Amount;
balance_type: string;
reference_date?: string | null;
last_change_date_time?: string | null;
};
export type StartAuthResponse = { url: string; authorization_id: string; psu_id_hash?: string };
export type SessionResponse = {
session_id: string;
accounts: BankAccount[];
aspsp: { name: string; country: string };
psu_type: string;
access: { valid_until: string };
};
export class EnableBankingError extends Error {
constructor(
readonly status: number,
readonly code: string | undefined,
message: string
) {
super(message);
this.name = 'EnableBankingError';
}
}
/** Reads ENABLEBANKING_APP_ID and ENABLEBANKING_KEY_PATH / ENABLEBANKING_PRIVATE_KEY. */
export function configFromEnv(env: Record<string, string | undefined> = process.env) {
const appId = env.ENABLEBANKING_APP_ID?.trim();
if (!appId) throw new Error('ENABLEBANKING_APP_ID is not set');
const keyPath = env.ENABLEBANKING_KEY_PATH?.trim();
const privateKeyPem = env.ENABLEBANKING_PRIVATE_KEY?.replace(/\\n/g, '\n');
if (!keyPath && !privateKeyPem)
throw new Error('Set ENABLEBANKING_KEY_PATH (preferred) or ENABLEBANKING_PRIVATE_KEY');
return { appId, keyPath, privateKeyPem } satisfies EnableBankingConfig;
}
export function createEnableBankingClient(config: EnableBankingConfig) {
const baseUrl = config.baseUrl ?? ENABLE_BANKING_API;
const doFetch = config.fetch ?? fetch;
let key: KeyObject | undefined;
let cached: { token: string; expires: number } | undefined;
function privateKey() {
if (!key) {
const pem =
config.privateKeyPem ?? (config.keyPath ? readFileSync(config.keyPath, 'utf8') : '');
if (!pem) throw new Error('No Enable Banking private key configured');
key = createPrivateKey(pem);
}
return key;
}
/** RS256 JWT, valid for one hour (the API accepts at most 24 h). */
async function token() {
const now = Math.floor(Date.now() / 1000);
if (cached && cached.expires - 60 > now) return cached.token;
const jwt = await new SignJWT({})
.setProtectedHeader({ typ: 'JWT', alg: 'RS256', kid: config.appId })
.setIssuer('enablebanking.com')
.setAudience('api.enablebanking.com')
.setIssuedAt(now)
.setExpirationTime(now + 3600)
.sign(privateKey());
cached = { token: jwt, expires: now + 3600 };
return jwt;
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const response = await doFetch(baseUrl + path, {
method,
headers: {
Authorization: `Bearer ${await token()}`,
Accept: 'application/json',
...(body === undefined ? {} : { 'Content-Type': 'application/json' })
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(60_000)
});
const text = await response.text();
let data: unknown;
try {
data = text ? JSON.parse(text) : undefined;
} catch {
data = text;
}
if (!response.ok) {
const detail = (data ?? {}) as { message?: string; error?: string; detail?: unknown };
throw new EnableBankingError(
response.status,
detail.error,
`Enable Banking ${method} ${path.split('?')[0]} failed (${response.status}${detail.error ? ' ' + detail.error : ''}): ${detail.message ?? text.slice(0, 300)}`
);
}
return data as T;
}
return {
token,
application: () =>
request<{
name: string;
kid: string;
environment: string;
active: boolean;
redirect_urls: string[];
countries: string[];
services: string[];
}>('GET', '/application'),
aspsps: (country: string, psuType = 'personal') =>
request<{
aspsps: {
name: string;
country: string;
maximum_consent_validity: number;
psu_types: string[];
}[];
}>('GET', `/aspsps?${new URLSearchParams({ country, psu_type: psuType, service: 'AIS' })}`),
startAuth: (input: {
aspsp: { name: string; country: string };
redirectUrl: string;
validUntil: Date;
psuType?: 'personal' | 'business';
state?: string;
language?: string;
}) =>
request<StartAuthResponse>('POST', '/auth', {
access: { valid_until: input.validUntil.toISOString() },
aspsp: input.aspsp,
state: input.state ?? randomUUID(),
redirect_url: input.redirectUrl,
psu_type: input.psuType ?? 'personal',
...(input.language ? { language: input.language } : {})
}),
createSession: (code: string) => request<SessionResponse>('POST', '/sessions', { code }),
getSession: (sessionId: string) =>
request<{
status: string;
accounts: string[];
access: { valid_until: string };
aspsp: { name: string; country: string };
}>('GET', `/sessions/${encodeURIComponent(sessionId)}`),
deleteSession: (sessionId: string) =>
request<{ message: string }>('DELETE', `/sessions/${encodeURIComponent(sessionId)}`),
accountDetails: (uid: string) =>
request<BankAccount>('GET', `/accounts/${encodeURIComponent(uid)}/details`),
balances: (uid: string) =>
request<{ balances: Balance[] }>('GET', `/accounts/${encodeURIComponent(uid)}/balances`),
/** All transactions from dateFrom (inclusive), following continuation_key pages. */
async transactions(
uid: string,
options: {
dateFrom?: string;
dateTo?: string;
status?: 'BOOK' | 'PDNG';
maxPages?: number;
} = {}
) {
const all: BankTransaction[] = [];
let continuationKey: string | undefined;
for (let page = 0; page < (options.maxPages ?? 200); page++) {
const query = new URLSearchParams();
if (options.dateFrom) query.set('date_from', options.dateFrom);
if (options.dateTo) query.set('date_to', options.dateTo);
if (options.status) query.set('transaction_status', options.status);
if (continuationKey) query.set('continuation_key', continuationKey);
const result = await request<{
transactions: BankTransaction[];
continuation_key?: string | null;
}>(
'GET',
`/accounts/${encodeURIComponent(uid)}/transactions${query.size ? '?' + query : ''}`
);
all.push(...(result.transactions ?? []));
continuationKey = result.continuation_key ?? undefined;
if (!continuationKey) break;
}
return all;
}
};
}
export type EnableBankingClient = ReturnType<typeof createEnableBankingClient>;
/** Extracts the authorisation code from a pasted redirect URL, query string or bare code. */
export function parseAuthCode(input: string): { code?: string; error?: string; state?: string } {
const value = input.trim();
if (!value) return {};
if (/^[\w-]+$/.test(value) && !value.includes('=')) return { code: value };
let params: URLSearchParams;
try {
params = new URL(value).searchParams;
} catch {
params = new URLSearchParams(value.replace(/^.*\?/, ''));
}
const error = params.get('error');
return {
code: params.get('code') ?? undefined,
state: params.get('state') ?? undefined,
error: error ? `${error}: ${params.get('error_description') ?? ''}`.trim() : undefined
};
}

121
src/lib/server/energy.ts Normal file
View File

@@ -0,0 +1,121 @@
import { db } from '#lib/server/db/index.js';
import { sql } from 'drizzle-orm';
import type { EnergyRow } from '#lib/energy/data.js';
// Read-only queries over the ned-collector tables in the `energy` schema. The app role
// already has USAGE + SELECT there; nothing on the site writes to these tables.
type Raw = Record<string, unknown>;
const num = (value: unknown) => (value === null || value === undefined ? null : Number(value));
const time = (value: unknown) => new Date(value as string | Date).getTime();
const rows = (result: unknown) => result as Raw[];
function energyRows(result: unknown): EnergyRow[] {
return rows(result).map((row) => ({
t: time(row.t),
type: String(row.type),
value: Number(row.value),
pct: num(row.pct),
ef: num(row.ef),
...(row.hours === undefined ? {} : { hours: Number(row.hours) }),
...(row.expected === undefined ? {} : { expected: Number(row.expected) })
}));
}
export async function loadEnergy() {
const [hourly, daily, forecast, monthly, yearly, gasNl, gasSites, runs, coverage] =
await Promise.all([
// Last 7 days of hourly measurements, counted back from the newest hour NED published.
db.execute(sql`WITH latest AS (
SELECT max(valid_from) AS t FROM energy.ned_utilization
WHERE granularity = 'Hour' AND classification = 'Current')
SELECT valid_from AS t, type, volume_kwh::float8 / 1000 AS value,
percentage::float8 AS pct, emission_factor::float8 AS ef
FROM energy.ned_utilization, latest
WHERE granularity = 'Hour' AND classification = 'Current'
AND valid_from > latest.t - interval '7 days'
ORDER BY 1, 2`),
// Daily totals (Amsterdam days) from hourly data, last 31 days. `expected` is 23 or 25
// on DST change days so partial days can be flagged.
db.execute(sql`WITH latest AS (
SELECT max(valid_from) AS t FROM energy.ned_utilization
WHERE granularity = 'Hour' AND classification = 'Current'),
days AS (
SELECT date_trunc('day', valid_from AT TIME ZONE 'Europe/Amsterdam') AS day, type,
sum(volume_kwh) AS kwh, sum(emission_kg) AS kg, count(*)::int AS hours
FROM energy.ned_utilization, latest
WHERE granularity = 'Hour' AND classification = 'Current'
AND valid_from > latest.t - interval '31 days'
GROUP BY 1, 2)
SELECT day AT TIME ZONE 'Europe/Amsterdam' AS t, type, kwh::float8 / 1000 AS value, hours,
(extract(epoch FROM (day + interval '1 day') AT TIME ZONE 'Europe/Amsterdam'
- day AT TIME ZONE 'Europe/Amsterdam') / 3600)::int AS expected,
(kg / nullif(kwh, 0))::float8 AS ef
FROM days ORDER BY 1, 2`),
db.execute(sql`WITH latest AS (
SELECT max(valid_from) AS t FROM energy.ned_utilization
WHERE granularity = 'Hour' AND classification = 'Current')
SELECT valid_from AS t, type, volume_kwh::float8 / 1000 AS value
FROM energy.ned_utilization, latest
WHERE granularity = 'Hour' AND classification = 'Forecast'
AND valid_from > latest.t AND valid_from <= latest.t + interval '8 days'
AND type IN ('Wind', 'WindOffshoreC', 'Solar', 'ElectricityLoad', 'ElectricityMix')
ORDER BY 1, 2`),
db.execute(sql`SELECT valid_from AS t, type, volume_kwh::float8 / 1e6 AS value,
percentage::float8 AS pct, emission_factor::float8 AS ef
FROM energy.ned_utilization
WHERE granularity = 'Month' AND classification = 'Current'
ORDER BY 1, 2`),
db.execute(sql`SELECT valid_from AS t, type, volume_kwh::float8 / 1e6 AS value,
emission_factor::float8 AS ef
FROM energy.ned_utilization
WHERE granularity = 'Year' AND classification = 'Current'
ORDER BY 1, 2`),
db.execute(sql`SELECT gas_day::text AS day, fill_pct::float8 AS fill, volume_twh::float8 AS twh
FROM energy.gas_storage_daily WHERE site = 'NL' ORDER BY gas_day`),
db.execute(sql`SELECT site, gas_day::text AS day, fill_pct::float8 AS fill,
volume_twh::float8 AS twh
FROM energy.gas_storage_latest WHERE site <> 'NL' ORDER BY volume_twh DESC`),
db.execute(sql`SELECT
(SELECT max(started_at) FROM energy.collector_runs) AS last_run,
(SELECT max(finished_at) FROM energy.collector_runs WHERE ok) AS last_ok,
(SELECT count(*)::int FROM energy.collector_runs
WHERE NOT ok AND started_at > now() - interval '7 days') AS failed_week`),
db.execute(sql`SELECT min(valid_from) AS first_hour, max(valid_from) AS last_hour,
count(DISTINCT valid_from)::int AS hours
FROM energy.ned_utilization WHERE granularity = 'Hour' AND classification = 'Current'`)
]);
const run = rows(runs)[0] ?? {};
const cover = rows(coverage)[0] ?? {};
return {
hourly: energyRows(hourly),
daily: energyRows(daily),
forecast: energyRows(forecast),
monthly: energyRows(monthly),
yearly: energyRows(yearly),
gas: rows(gasNl).map((row) => ({
day: String(row.day),
fill: Number(row.fill),
twh: Number(row.twh)
})),
gasSites: rows(gasSites).map((row) => ({
site: String(row.site),
day: String(row.day),
fill: Number(row.fill),
twh: Number(row.twh)
})),
collector: {
lastRun: run.last_run ? time(run.last_run) : null,
lastOk: run.last_ok ? time(run.last_ok) : null,
failedWeek: Number(run.failed_week ?? 0)
},
coverage: {
firstHour: cover.first_hour ? time(cover.first_hour) : null,
lastHour: cover.last_hour ? time(cover.last_hour) : null,
hours: Number(cover.hours ?? 0)
}
};
}
export type EnergyData = Awaited<ReturnType<typeof loadEnergy>>;

View File

@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { cents, csvDate, importCents, parseCsv, parseTransactionImport } from './money-tracker';
import {
cents,
csvDate,
importCents,
parseCsv,
parseTransactionImport,
planImport
} from './money-tracker';
describe('transaction import parsing', () => {
it('handles quoted commas and semicolon-separated European amounts', () => {
@@ -63,3 +70,50 @@ describe('transaction import parsing', () => {
).toThrow('Quote values containing commas');
});
});
describe('revolut statements and import dedupe', () => {
const statement =
'Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance\n' +
'CARD_PAYMENT,Current,2026-10-01 10:00:00,2026-10-02 09:00:00,Albert Heijn,-12.34,0.00,EUR,COMPLETED,100.00\n' +
'TOPUP,Current,2026-10-03 10:00:00,,Pending top-up,50.00,0.00,EUR,PENDING,\n' +
'TRANSFER,Current,2026-10-04 10:00:00,2026-10-04 10:00:01,To Joint,-100.00,0.50,EUR,COMPLETED,0.00\n' +
'CARD_PAYMENT,Current,2026-10-05 10:00:00,2026-10-05 11:00:00,Coffee,-3.00,0.00,USD,COMPLETED,0.00\n';
it('keeps completed EUR rows, uses the completed date and deducts fees', () => {
const rows = parseTransactionImport(statement, 'Revolut');
expect(rows.map((row) => [row.date, row.account, row.description, row.amountCents])).toEqual([
['2026-10-02', 'Revolut', 'Albert Heijn', -1234],
['2026-10-04', 'Revolut', 'To Joint', -10050]
]);
expect(rows[0].externalId).toMatch(/^csv:[0-9a-f]{32}$/);
expect(parseTransactionImport(statement, 'Revolut')[0].externalId).toBe(rows[0].externalId);
expect(() => parseTransactionImport(statement)).toThrow('Choose which account');
});
it('gives identical rows in one file distinct, stable ids', () => {
const file =
'date,account,description,amount\n2026-10-01,Cash,Kiosk,-2\n2026-10-01,Cash,Kiosk,-2\n';
const [a, b] = parseTransactionImport(file);
expect(a.externalId).not.toBe(b.externalId);
expect(parseTransactionImport(file).map((row) => row.externalId)).toEqual([
a.externalId,
b.externalId
]);
});
it('skips rows already imported or already entered by hand (±1 day)', () => {
const rows = [
{ accountId: 1, date: '2026-10-02', amountCents: -1234, externalId: 'csv:a' },
{ accountId: 1, date: '2026-10-02', amountCents: -1234, externalId: 'csv:b' },
{ accountId: 1, date: '2026-10-04', amountCents: -500, externalId: 'csv:c' },
{ accountId: 2, date: '2026-10-04', amountCents: -500, externalId: 'csv:d' }
];
const existing = [
{ id: 10, accountId: 1, date: '2026-10-01', amountCents: -1234, externalId: null },
{ id: 11, accountId: 1, date: '2026-10-04', amountCents: -500, externalId: 'csv:c' }
];
const { insert, duplicates } = planImport(rows, existing);
expect(insert.map((row) => row.externalId)).toEqual(['csv:b', 'csv:d']);
expect(duplicates).toBe(2);
});
});

View File

@@ -1,4 +1,5 @@
import { fail } from '@sveltejs/kit';
import { createHash } from 'node:crypto';
export const utilityKinds = ['electricity', 'gas', 'water'] as const;
export const cadences = ['weekly', 'monthly', 'yearly'] as const;
@@ -130,21 +131,93 @@ export type ImportedTransaction = {
account: string;
description: string;
amountCents: number;
/** Stable per-row id ("csv:<hash>") so importing the same file twice adds nothing. */
externalId: string;
};
export function parseTransactionImport(input: string): ImportedTransaction[] {
const rows = parseCsv(input);
if (rows.length < 2 || rows.length > 20_001)
throw new Error('The file must have a header and 1–20,000 rows.');
const header = rows.shift()!.map((value) =>
export type ImportFormat = 'tracker' | 'revolut';
function headerOf(rows: string[][]) {
return rows[0].map((value) =>
value
.replace(/^\uFEFF/, '')
.trim()
.toLowerCase()
);
}
/** Revolut app statement export: Type, Product, Started Date, Completed Date, Description, Amount, Fee, Currency, State, Balance. */
export function isRevolutHeader(header: string[]) {
return ['type', 'started date', 'description', 'amount', 'currency', 'state'].every((key) =>
header.includes(key)
);
}
function importId(parts: (string | number)[], seen: Map<string, number>) {
const base = parts.join('\u001f');
// Identical rows in one file (two coffees, same day) stay distinct but stable.
const occurrence = (seen.get(base) ?? 0) + 1;
seen.set(base, occurrence);
return (
'csv:' + createHash('sha256').update(`${base}\u001f${occurrence}`).digest('hex').slice(0, 32)
);
}
/**
* Parses either the tracker's own export (date, account, description, amount) or a Revolut
* statement. Revolut files have no account column, so `defaultAccount` names the tracker
* account they belong to; only COMPLETED EUR rows are kept and the fee is deducted.
*/
export function parseTransactionImport(
input: string,
defaultAccount?: string
): ImportedTransaction[] {
const rows = parseCsv(input);
if (rows.length < 2 || rows.length > 20_001)
throw new Error('The file must have a header and 1–20,000 rows.');
const header = headerOf(rows);
rows.shift();
const seen = new Map<string, number>();
if (isRevolutHeader(header)) {
const account = defaultAccount?.trim();
if (!account) throw new Error('Choose which account this Revolut statement belongs to.');
const col = (key: string) => header.indexOf(key);
const parsed: ImportedTransaction[] = [];
for (const [index, row] of rows.entries()) {
const state = (row[col('state')] ?? '').trim().toUpperCase();
if (state && state !== 'COMPLETED') continue;
const currency = (row[col('currency')] ?? 'EUR').trim().toUpperCase();
if (currency && currency !== 'EUR') continue;
const started = (row[col('started date')] ?? '').trim();
const completed = col('completed date') >= 0 ? (row[col('completed date')] ?? '').trim() : '';
const date = csvDate((completed || started).slice(0, 10));
const amount = importCents(row[col('amount')]);
const fee = col('fee') >= 0 ? (importCents(row[col('fee')] || '0') ?? undefined) : 0;
const description = (row[col('description')] ?? '').trim().slice(0, 500) || 'Revolut';
if (!date || amount == null || fee == null)
throw new Error(`Invalid data in statement row ${index + 1}. No rows were imported.`);
const amountCents = amount - fee;
if (!amountCents) continue;
parsed.push({
date,
account,
description,
amountCents,
externalId: importId(
['revolut', account.toLowerCase(), started, amount, fee, description],
seen
)
});
}
if (!parsed.length) throw new Error('No completed EUR transactions found in this statement.');
return parsed.sort((a, b) => a.date.localeCompare(b.date));
}
const columns = ['date', 'account', 'description', 'amount'].map((key) => header.indexOf(key));
if (columns.some((index) => index < 0))
throw new Error('Headers must include date, account, description and amount.');
const accountColumn = columns[1];
if (columns[0] < 0 || columns[2] < 0 || columns[3] < 0 || (accountColumn < 0 && !defaultAccount))
throw new Error(
'Headers must include date, account, description and amount (or choose an account), or be a Revolut statement.'
);
const parsed: ImportedTransaction[] = [];
for (const [index, row] of rows.entries()) {
if (row.length !== header.length)
@@ -152,7 +225,8 @@ export function parseTransactionImport(input: string): ImportedTransaction[] {
`Data row ${index + 1} has ${row.length} columns; expected ${header.length}. Quote values containing commas. No rows were imported.`
);
const date = csvDate(row[columns[0]] ?? '');
const account = (row[columns[1]] ?? '').trim();
const account =
(accountColumn >= 0 ? (row[accountColumn] ?? '') : '').trim() || defaultAccount?.trim() || '';
const description = (row[columns[2]] ?? '').trim();
const amountCents = importCents(row[columns[3]]);
if (
@@ -164,7 +238,64 @@ export function parseTransactionImport(input: string): ImportedTransaction[] {
amountCents == null
)
throw new Error(`Invalid data in transaction ${index + 1}. No rows were imported.`);
parsed.push({ date, account, description, amountCents });
parsed.push({
date,
account,
description,
amountCents,
externalId: importId([date, account.toLowerCase(), description, amountCents], seen)
});
}
return parsed.sort((a, b) => a.date.localeCompare(b.date));
}
export type ExistingImportRow = {
id: number;
accountId: number;
date: string;
amountCents: number;
externalId: string | null;
};
function daysApart(a: string, b: string) {
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
}
/**
* Splits imported rows into new ones and duplicates: same import id already stored, or an
* existing row on the same account with the same amount within ±1 day (each existing row
* can only absorb one imported row).
*/
export function planImport<
T extends { accountId: number; date: string; amountCents: number; externalId: string }
>(rows: T[], existing: ExistingImportRow[]) {
const ids = new Set(
existing.flatMap((row) => (row.externalId ? [`${row.accountId}:${row.externalId}`] : []))
);
const byKey = new Map<string, ExistingImportRow[]>();
for (const row of existing) {
const key = `${row.accountId}:${row.amountCents}`;
byKey.set(key, [...(byKey.get(key) ?? []), row]);
}
const claimed = new Set<number>();
const insert: T[] = [];
let duplicates = 0;
for (const row of rows) {
const idKey = `${row.accountId}:${row.externalId}`;
if (ids.has(idKey)) {
duplicates++;
continue;
}
const match = (byKey.get(`${row.accountId}:${row.amountCents}`) ?? [])
.filter((candidate) => !claimed.has(candidate.id) && daysApart(candidate.date, row.date) <= 1)
.sort((a, b) => daysApart(a.date, row.date) - daysApart(b.date, row.date))[0];
if (match) {
claimed.add(match.id);
duplicates++;
continue;
}
ids.add(idKey);
insert.push(row);
}
return { insert, duplicates };
}

View File

@@ -30,6 +30,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/projects/animaldex/admin': 'Animaldex admin',
'/projects/derby': 'Derby Lens',
'/projects/cs-prices': 'CS Prices',
'/projects/energy': 'Energy',
'/projects/game-tracker': 'Game Tracker',
'/projects/language-learner': 'Language Learner',
'/projects/money-tracker': 'Money Tracker',

View File

@@ -0,0 +1,39 @@
<svelte:head><title>Privacy · stanrunge.dev</title></svelte:head>
<article class="mx-auto max-w-2xl space-y-4 px-6 py-10 leading-relaxed">
<h1 class="text-2xl font-bold">Privacy</h1>
<p class="text-sm opacity-70">Last updated 24 October 2026</p>
<p>
stanrunge.dev is a personal website run by Stan Runge in the Netherlands. This page explains
what personal data it handles and why.
</p>
<h2 class="pt-2 text-lg font-semibold">Accounts</h2>
<p>
If you register, the site stores your name, email address, and the sign-in methods you link
(such as Google or osu!), so you can log in. If you use a password, only a secure hash of it is
stored. Session cookies keep you signed in; there are no advertising or third-party tracking
cookies.
</p>
<h2 class="pt-2 text-lg font-semibold">Bank data (money tracker)</h2>
<p>
The money tracker is a private tool used only by the site owner. With the account holders'
explicit consent, it reads account details, balances and booked transactions from Revolut
through Enable Banking Oy, a licensed account information service provider. This data is used
only to show the account holders their own finances. It is stored in a database on a server
controlled by the site owner, is never sold or shared, and consent can be withdrawn at any time
in the bank app or by asking the site owner, after which no further data is fetched. Access is
limited to read-only account information; no payments can be made.
</p>
<h2 class="pt-2 text-lg font-semibold">Hosting and logs</h2>
<p>
The site is self-hosted. Web server logs (IP address, time, requested page) are kept briefly for
security and troubleshooting. Backups are encrypted.
</p>
<h2 class="pt-2 text-lg font-semibold">Your rights</h2>
<p>
Under the GDPR you can ask to see, correct, export or delete your data, or object to its use.
Contact the site owner to do so. You may also complain to the Autoriteit Persoonsgegevens.
</p>
<h2 class="pt-2 text-lg font-semibold">Contact</h2>
<p>Stan Runge, via the contact details on the home page of stanrunge.dev.</p>
</article>

View File

@@ -0,0 +1,4 @@
import { loadEnergy } from '#lib/server/energy.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => ({ energy: await loadEnergy() });

View File

@@ -0,0 +1,896 @@
<script lang="ts">
import * as Tabs from '#lib/components/ui/tabs/index.js';
import EnergyChart, { type ChartTheme } from '#lib/energy/EnergyChart.svelte';
import {
SOURCES,
fillGaps,
gaps,
latestSnapshot,
seasonalTime,
toBuckets,
type Bucket
} from '#lib/energy/data.js';
import { relativeTime } from '#lib/relative-time.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let energy = $derived(data.energy);
const HOUR = 3_600_000;
const zone = 'Europe/Amsterdam';
const formatHour = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
weekday: 'short',
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
const formatTick = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
const formatDay = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
weekday: 'short',
day: 'numeric',
month: 'short'
});
const formatDate = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeZone: zone });
const formatMonth = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
month: 'short',
year: 'numeric'
});
const formatYear = new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric' });
const formatSeason = new Intl.DateTimeFormat('en-GB', {
timeZone: 'UTC',
day: 'numeric',
month: 'short'
});
const whole = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 0 });
const one = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 });
const percent = new Intl.NumberFormat('en-GB', { style: 'percent', maximumFractionDigits: 0 });
const escapeHtml = (value: string) =>
value.replace(
/[&<>"']/g,
(character) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!
);
let hourly = $derived(toBuckets(energy.hourly));
let daily = $derived(toBuckets(energy.daily));
let monthly = $derived(toBuckets(energy.monthly));
let yearly = $derived(toBuckets(energy.yearly));
let forecast = $derived(toBuckets(energy.forecast));
let now = $derived(latestSnapshot(hourly));
let incompleteHours = $derived(hourly.filter((bucket) => bucket.incomplete));
let hourGaps = $derived(gaps(hourly.map((bucket) => bucket.t)));
let latestGas = $derived(energy.gas.at(-1));
let latestPeriod = $derived(energy.coverage.lastHour ?? Date.now());
type Range = 'day' | 'week' | 'month' | 'year' | 'years';
let range = $state<Range>('week');
const rangeLabels: Record<Range, string> = {
day: 'day',
week: 'week',
month: 'month',
year: '12 months',
years: 'all years'
};
function axis(theme: ChartTheme) {
return {
axisLine: { lineStyle: { color: theme.border } },
axisLabel: { color: theme.muted },
splitLine: { lineStyle: { color: theme.border } }
};
}
function tooltip(theme: ChartTheme, extra: Record<string, unknown> = {}) {
return {
backgroundColor: theme.surface,
borderColor: theme.border,
confine: true,
textStyle: { color: theme.text },
...extra
};
}
function legend(theme: ChartTheme) {
return { top: 0, type: 'scroll', textStyle: { color: theme.muted }, itemWidth: 14 };
}
// YYYY-MM-DD of an instant in Amsterdam, used to compare calendar periods.
const localDay = new Intl.DateTimeFormat('en-CA', { timeZone: zone });
const monthKey = (t: number) => localDay.format(t).slice(0, 7);
const yearKey = (t: number) => localDay.format(t).slice(0, 4);
/** Is the bucket's calendar period still running (current month/year)? */
const isPartial = (bucket: Partial<Bucket>, key: (t: number) => string) =>
key(bucket.t!) === key(latestPeriod);
let trend = $derived.by(() => {
if (range === 'day' || range === 'week') {
const from = latestPeriod - (range === 'day' ? 24 : 24 * 7) * HOUR;
const points = fillGaps(
hourly.filter((bucket) => bucket.t > from),
HOUR
).map((bucket) => ('missing' in bucket || bucket.incomplete ? { t: bucket.t } : bucket));
return { kind: 'hour' as const, unit: 'MWh', scale: 1, points: points as Partial<Bucket>[] };
}
if (range === 'month')
return { kind: 'day' as const, unit: 'GWh', scale: 1000, points: daily as Partial<Bucket>[] };
if (range === 'year')
return {
kind: 'month' as const,
unit: 'GWh',
scale: 1,
points: monthly as Partial<Bucket>[]
};
return {
kind: 'year' as const,
unit: 'TWh',
scale: 1000,
// Before 2021 NED only publishes wind and solar per year, so those totals would mislead.
points: yearly.filter((bucket) => bucket.mix !== null) as Partial<Bucket>[]
};
});
function trendLabel(t: number) {
const kind = trend.kind;
if (kind === 'day') return formatDay.format(t);
if (kind === 'month') return formatMonth.format(t);
return formatYear.format(t);
}
function partialNote(bucket: Partial<Bucket>) {
if (trend.kind === 'day' && bucket.hours !== undefined && bucket.expected !== undefined)
if (bucket.hours < bucket.expected) return `${bucket.hours}/${bucket.expected} h`;
if (trend.kind === 'month' && isPartial(bucket, monthKey)) return 'so far';
if (trend.kind === 'year' && isPartial(bucket, yearKey)) return 'so far';
return '';
}
let trendOption = $derived((theme: ChartTheme) => {
const { kind, unit, scale, points } = trend;
const hourlyAxis = kind === 'hour';
const value = (raw: number | null | undefined) =>
raw === null || raw === undefined ? null : raw / scale;
const x = (bucket: Partial<Bucket>) => (hourlyAxis ? bucket.t! : trendLabel(bucket.t!));
const categories = hourlyAxis
? undefined
: points.map((bucket) => {
const note = partialNote(bucket);
return note ? `${trendLabel(bucket.t!)}*` : trendLabel(bucket.t!);
});
const series: Record<string, unknown>[] = SOURCES.map((source) => ({
name: source.label,
type: hourlyAxis ? 'line' : 'bar',
stack: 'mix',
areaStyle: hourlyAxis ? { opacity: 0.85 } : undefined,
showSymbol: false,
lineStyle: { width: 0 },
itemStyle: { color: source.color },
emphasis: { focus: 'series' },
data: points.map((bucket, index) =>
hourlyAxis
? [x(bucket), value(bucket.sources?.[source.type]?.value)]
: [categories![index], value(bucket.sources?.[source.type]?.value)]
)
}));
series.push({
name: 'Consumption',
type: 'line',
showSymbol: !hourlyAxis,
symbolSize: 5,
lineStyle: { width: 2, type: 'dashed', color: theme.text },
itemStyle: { color: theme.text },
data: points.map((bucket, index) => [
hourlyAxis ? x(bucket) : categories![index],
value(bucket.load)
])
});
const showCo2 = kind !== 'year';
if (showCo2)
series.push({
name: 'CO₂ intensity',
type: 'line',
yAxisIndex: 1,
showSymbol: false,
lineStyle: { width: 1.5, color: '#ef4444' },
itemStyle: { color: '#ef4444' },
data: points.map((bucket, index) => [
hourlyAxis ? x(bucket) : categories![index],
bucket.ef === null || bucket.ef === undefined ? null : Math.round(bucket.ef * 1000)
])
});
return {
animation: false,
backgroundColor: 'transparent',
legend: legend(theme),
grid: { left: 64, right: showCo2 ? 64 : 20, top: 48, bottom: hourlyAxis ? 64 : 36 },
xAxis: hourlyAxis
? {
type: 'time',
...axis(theme),
splitLine: { show: false },
axisLabel: {
color: theme.muted,
hideOverlap: true,
formatter: (t: number) => formatTick.format(t)
}
}
: { type: 'category', data: categories, ...axis(theme), splitLine: { show: false } },
yAxis: [
{
type: 'value',
name: unit,
nameTextStyle: { color: theme.muted },
...axis(theme),
axisLabel: { color: theme.muted, formatter: (v: number) => whole.format(v) }
},
...(showCo2
? [
{
type: 'value',
name: 'g CO₂/kWh',
nameTextStyle: { color: theme.muted },
...axis(theme),
splitLine: { show: false },
min: 0
}
]
: [])
],
dataZoom: hourlyAxis
? [
{ type: 'inside' },
{
type: 'slider',
bottom: 10,
height: 18,
borderColor: theme.border,
textStyle: { color: theme.muted },
fillerColor: 'rgba(127,127,127,.15)'
}
]
: [],
tooltip: tooltip(theme, {
trigger: 'axis',
formatter: (
params: { seriesName: string; marker: string; value: [number | string, number | null] }[]
) => {
if (!params.length) return '';
const head = params[0].value[0];
const index = hourlyAxis ? -1 : categories!.indexOf(String(head));
const bucket = hourlyAxis ? points.find((point) => point.t === head) : points[index];
const title = hourlyAxis ? formatHour.format(Number(head)) : trendLabel(bucket!.t!);
const note = bucket ? partialNote(bucket) : '';
const lines = params
.filter((param) => param.value[1] !== null && param.value[1] !== undefined)
.map((param) => {
const suffix = param.seriesName === 'CO₂ intensity' ? ' g/kWh' : ` ${unit}`;
return `${param.marker}${escapeHtml(param.seriesName)}: <b>${one.format(param.value[1]!)}${suffix}</b>`;
});
if (!lines.length) return `${title}<br>no data for this hour`;
const total = bucket?.sum
? `<br>Generation: <b>${one.format(bucket.sum / scale)} ${unit}</b>`
: '';
return `${title}${note ? ` (${note})` : ''}${total}<br>${lines.join('<br>')}`;
}
}),
series
};
});
let mixOption = $derived((theme: ChartTheme) => ({
animation: false,
backgroundColor: 'transparent',
tooltip: tooltip(theme, {
trigger: 'item',
formatter: (param: { name: string; value: number; percent: number }) =>
`${escapeHtml(param.name)}<br><b>${whole.format(param.value)} MWh</b> · ${one.format(param.percent)}%`
}),
series: [
{
type: 'pie',
radius: ['48%', '78%'],
itemStyle: { borderColor: theme.surface, borderWidth: 2 },
label: { color: theme.muted, formatter: '{b}\n{d}%' },
data: (now?.sources ?? [])
.filter((source) => source.value > 0)
.map((source) => ({
name: source.label,
value: Math.round(source.value),
itemStyle: { color: source.color }
}))
}
]
}));
let utilizationOption = $derived((theme: ChartTheme) => {
const sources = [...(now?.sources ?? [])].reverse();
return {
animation: false,
backgroundColor: 'transparent',
grid: { left: 96, right: 48, top: 8, bottom: 28 },
xAxis: {
type: 'value',
max: 100,
...axis(theme),
axisLabel: { color: theme.muted, formatter: '{value}%' }
},
yAxis: {
type: 'category',
data: sources.map((source) => source.label),
...axis(theme),
splitLine: { show: false }
},
tooltip: tooltip(theme, {
trigger: 'item',
formatter: (param: { name: string; value: number }) =>
`${escapeHtml(param.name)}<br>running at <b>${one.format(param.value)}%</b> of capacity`
}),
series: [
{
type: 'bar',
barMaxWidth: 16,
label: {
show: true,
position: 'right',
color: theme.muted,
formatter: (param: { value: number }) => `${whole.format(param.value)}%`
},
data: sources.map((source) => ({
value: source.utilization === null ? 0 : Math.round(source.utilization * 1000) / 10,
itemStyle: { color: source.color }
}))
}
]
};
});
let forecastOption = $derived((theme: ChartTheme) => {
const weather = SOURCES.filter((source) =>
['WindOffshoreC', 'Wind', 'Solar'].includes(source.type)
);
return {
animation: false,
backgroundColor: 'transparent',
legend: legend(theme),
grid: { left: 64, right: 20, top: 48, bottom: 32 },
xAxis: {
type: 'time',
...axis(theme),
splitLine: { show: false },
axisLabel: {
color: theme.muted,
hideOverlap: true,
formatter: (t: number) => formatDay.format(t)
}
},
yAxis: { type: 'value', name: 'MWh', nameTextStyle: { color: theme.muted }, ...axis(theme) },
tooltip: tooltip(theme, {
trigger: 'axis',
valueFormatter: (v: number | null) => (v === null ? '–' : `${whole.format(v)} MWh`)
}),
series: [
...weather.map((source) => ({
name: source.label,
type: 'line',
stack: 'weather',
areaStyle: { opacity: 0.85 },
showSymbol: false,
lineStyle: { width: 0 },
itemStyle: { color: source.color },
data: forecast.map((bucket) => [bucket.t, bucket.sources[source.type]?.value ?? null])
})),
{
name: 'Total generation',
type: 'line',
showSymbol: false,
lineStyle: { width: 1.5, color: theme.muted },
itemStyle: { color: theme.muted },
data: forecast.map((bucket) => [bucket.t, bucket.mix])
},
{
name: 'Consumption',
type: 'line',
showSymbol: false,
lineStyle: { width: 2, type: 'dashed', color: theme.text },
itemStyle: { color: theme.text },
data: forecast.map((bucket) => [bucket.t, bucket.load])
}
]
};
});
let seasonOption = $derived((theme: ChartTheme) => {
const years: Record<string, [number, number][]> = {};
for (const row of energy.gas)
(years[row.day.slice(0, 4)] ??= []).push([seasonalTime(row.day), row.fill]);
const list = Object.entries(years).filter(([, points]) => points.length > 30);
const current = list.at(-1)?.[0];
return {
animation: false,
backgroundColor: 'transparent',
legend: legend(theme),
grid: { left: 48, right: 20, top: 48, bottom: 32 },
xAxis: {
type: 'time',
min: Date.UTC(2000, 0, 1),
max: Date.UTC(2000, 11, 31),
...axis(theme),
splitLine: { show: false },
axisLabel: {
color: theme.muted,
formatter: (t: number) => formatSeason.format(t).split(' ')[1]
}
},
yAxis: {
type: 'value',
min: 0,
max: 100,
...axis(theme),
axisLabel: { color: theme.muted, formatter: '{value}%' }
},
tooltip: tooltip(theme, {
trigger: 'axis',
axisPointer: {
label: { formatter: ({ value }: { value: number }) => formatSeason.format(value) }
},
valueFormatter: (v: number) => `${one.format(v)}%`
}),
series: list.map(([year, points], index) => ({
name: year,
type: 'line',
showSymbol: false,
data: points,
lineStyle: { width: year === current ? 3 : 1.3, opacity: year === current ? 1 : 0.7 },
itemStyle: {
color:
year === current
? '#f97316'
: `hsl(${200 + index * 22} 55% ${theme.dark ? '68%' : '42%'})`
},
z: year === current ? 3 : 2
}))
};
});
let gasPowerOption = $derived((theme: ChartTheme) => {
const months = monthly.filter((bucket) => bucket.sum > 0);
// Fill level on the last reported day of each month.
const fillAt = (t: number) =>
energy.gas.findLast((row) => row.day.startsWith(monthKey(t)))?.fill ?? null;
const labels = months.map((bucket) =>
isPartial(bucket, monthKey)
? `${formatMonth.format(bucket.t)}*`
: formatMonth.format(bucket.t)
);
const share = (bucket: Bucket, type: 'FossilGasPower' | 'WKK Total') =>
Math.round(((bucket.sources[type]?.value ?? 0) / bucket.sum) * 1000) / 10;
return {
animation: false,
backgroundColor: 'transparent',
legend: legend(theme),
grid: { left: 48, right: 20, top: 48, bottom: 32 },
xAxis: { type: 'category', data: labels, ...axis(theme), splitLine: { show: false } },
yAxis: {
type: 'value',
min: 0,
max: 100,
...axis(theme),
axisLabel: { color: theme.muted, formatter: '{value}%' }
},
tooltip: tooltip(theme, {
trigger: 'axis',
valueFormatter: (v: number | null) => (v === null ? '–' : `${one.format(v)}%`)
}),
series: [
{
name: 'Fossil gas power (share of generation)',
type: 'bar',
stack: 'gas',
itemStyle: { color: '#f97316' },
data: months.map((bucket) => share(bucket, 'FossilGasPower'))
},
{
name: 'CHP (WKK, mostly gas)',
type: 'bar',
stack: 'gas',
itemStyle: { color: '#fdba74' },
data: months.map((bucket) => share(bucket, 'WKK Total'))
},
{
name: 'Gas storage fill (month end)',
type: 'line',
symbolSize: 6,
lineStyle: { width: 2.5, color: '#0ea5e9' },
itemStyle: { color: '#0ea5e9' },
data: months.map((bucket) => fillAt(bucket.t))
}
]
};
});
let sitesOption = $derived((theme: ChartTheme) => {
const sites = [...energy.gasSites].reverse();
return {
animation: false,
backgroundColor: 'transparent',
grid: { left: 96, right: 96, top: 8, bottom: 28 },
xAxis: {
type: 'value',
max: 100,
...axis(theme),
axisLabel: { color: theme.muted, formatter: '{value}%' }
},
yAxis: {
type: 'category',
data: sites.map((site) => site.site),
...axis(theme),
splitLine: { show: false }
},
tooltip: tooltip(theme, {
trigger: 'item',
formatter: (param: { dataIndex: number }) => {
const site = sites[param.dataIndex];
return `${escapeHtml(site.site)} · ${site.day}<br><b>${one.format(site.fill)}%</b> full · ${one.format(site.twh)} TWh`;
}
}),
series: [
{
type: 'bar',
barMaxWidth: 16,
itemStyle: { color: '#0ea5e9' },
label: {
show: true,
position: 'right',
color: theme.muted,
formatter: (param: { dataIndex: number }) =>
`${whole.format(sites[param.dataIndex].fill)}% · ${one.format(sites[param.dataIndex].twh)} TWh`
},
data: sites.map((site) => site.fill)
}
]
};
});
let missingHours = $derived(hourGaps.reduce((sum, gap) => sum + gap.hours, 0));
let clock = $state(Date.now());
$effect(() => {
const timer = setInterval(() => (clock = Date.now()), 60_000);
return () => clearInterval(timer);
});
</script>
<svelte:head>
<title>Energy · Stan Runge</title>
<meta
name="description"
content="Dutch electricity mix, utilization, trends and gas storage from NED.nl data, updated hourly."
/>
</svelte:head>
<div class="energy-page">
<header class="intro">
<div>
<p class="eyebrow">grid archive / netherlands</p>
<h1>Energy</h1>
<p class="lede">
Where Dutch electricity comes from hour by hour, how hard each source is running, and how
full the gas storages are. Data from NED.nl, collected hourly.
</p>
</div>
<div class="stats">
{#if now}
<div><strong>{percent.format(now.renewableShare)}</strong><span>renewable now</span></div>
{#if now.ef !== null}
<div><strong>{whole.format(now.ef * 1000)}</strong><span>g CO₂ / kWh</span></div>
{/if}
{/if}
{#if latestGas}
<div><strong>{one.format(latestGas.fill)}%</strong><span>gas storage full</span></div>
{/if}
</div>
</header>
{#if !now}
<section class="panel empty">No electricity data has been collected yet.</section>
{:else}
<section class="grid two">
<div class="panel">
<div class="panel-heading">
<h2>Current mix</h2>
<span class="muted">{formatHour.format(now.t)} · {whole.format(now.total)} MWh</span>
</div>
<EnergyChart
option={mixOption}
label="Electricity generation mix for the latest hour"
height="320px"
/>
<dl class="facts">
<div>
<dt>Low-carbon</dt>
<dd>{percent.format(now.lowCarbonShare)}</dd>
</div>
<div>
<dt>Fossil</dt>
<dd>{percent.format(now.fossilShare)}</dd>
</div>
{#if now.load !== null}
<div>
<dt>{now.load > now.total ? 'Net import' : 'Net export'}</dt>
<dd>{whole.format(Math.abs(now.total - now.load))} MWh</dd>
</div>
{/if}
</dl>
</div>
<div class="panel">
<div class="panel-heading">
<h2>Utilization</h2>
<span class="muted">share of installed capacity in use</span>
</div>
<EnergyChart
option={utilizationOption}
label="Utilization of each electricity source for the latest hour"
height="320px"
/>
<p class="note">
NED's load factor per source. Nuclear and "other" are reported near 100% almost
constantly, so treat those as estimates.
</p>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<h2>Generation and consumption</h2>
<Tabs.Root bind:value={range}>
<Tabs.List>
{#each Object.entries(rangeLabels) as [value, label] (value)}
<Tabs.Trigger {value}>{label}</Tabs.Trigger>
{/each}
</Tabs.List>
</Tabs.Root>
</div>
<EnergyChart
option={trendOption}
label={`Electricity generation by source, ${rangeLabels[range]}`}
/>
<p class="note">
Stacked: generation by source. Dashed: consumption (gap above it is export). Red: grid CO₂
intensity. * = incomplete (still running or hours missing).
{#if range === 'month' && daily.length < 28}
Hourly collection started {formatDate.format(energy.coverage.firstHour ?? now.t)}, so this
fills up over the coming weeks.
{/if}
</p>
</section>
{#if forecast.length}
<section class="panel">
<div class="panel-heading">
<h2>Next days</h2>
<span class="muted">NED forecast for wind, solar, generation and consumption</span>
</div>
<EnergyChart
option={forecastOption}
label="Forecast of wind and solar generation and consumption"
height="320px"
/>
</section>
{/if}
{/if}
<section class="grid two">
<div class="panel">
<div class="panel-heading">
<h2>Gas storage by year</h2>
{#if latestGas}
<span class="muted"
>{one.format(latestGas.twh)} TWh on {formatDate.format(new Date(latestGas.day))}</span
>
{/if}
</div>
<EnergyChart
option={seasonOption}
label="Dutch gas storage fill level per year"
height="340px"
/>
</div>
<div class="panel">
<div class="panel-heading">
<h2>Gas vs electricity</h2>
<span class="muted">monthly</span>
</div>
<EnergyChart
option={gasPowerOption}
label="Share of electricity from gas compared with gas storage fill"
height="340px"
/>
<p class="note">
NED has no gas consumption series here, so this compares how much electricity is made from
gas with how full storage is.
</p>
</div>
</section>
{#if energy.gasSites.length}
<section class="panel">
<div class="panel-heading">
<h2>Storage sites</h2>
<span class="muted">latest day per site</span>
</div>
<EnergyChart
option={sitesOption}
label="Fill level per Dutch gas storage site"
height="240px"
/>
</section>
{/if}
<footer class="coverage">
<p>
{#if energy.coverage.firstHour && energy.coverage.lastHour}
Hourly data {formatHour.format(energy.coverage.firstHour)} – {formatHour.format(
energy.coverage.lastHour
)} ({whole.format(energy.coverage.hours)} hours{missingHours
? `, ${missingHours} missing in the last week`
: ''}{incompleteHours.length
? `, ${incompleteHours.length} still incomplete and left out`
: ''}).
{/if}
{#if monthly.length}
Monthly from {formatMonth.format(monthly[0].t)}, yearly from {formatYear.format(
yearly[0]?.t ?? monthly[0].t
)}.
{/if}
{#if energy.gas.length}
Gas storage from {formatDate.format(new Date(energy.gas[0].day))}.
{/if}
</p>
<p>
Source: <a href="https://ned.nl" rel="external noopener">NED.nl</a>.
{#if energy.collector.lastOk}
Last successful collection {relativeTime(energy.collector.lastOk, clock)}{energy.collector
.failedWeek
? ` · ${energy.collector.failedWeek} failed runs this week`
: ''}.
{/if}
</p>
</footer>
</div>
<style>
.energy-page {
max-width: 1600px;
margin: 0 auto;
padding: 3rem clamp(1rem, 4vw, 4rem) 5rem;
color: var(--site-text);
}
.intro {
display: flex;
justify-content: space-between;
align-items: end;
gap: 2rem;
margin-bottom: 2.5rem;
}
.eyebrow {
color: var(--site-muted);
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.67rem;
font-weight: 700;
}
h1 {
font-size: clamp(2.8rem, 6vw, 5.2rem);
line-height: 1;
letter-spacing: -0.075em;
margin: 0.7rem 0 1rem;
}
h2 {
font-size: 1.25rem;
letter-spacing: -0.04em;
}
.lede {
color: var(--site-muted);
max-width: 40rem;
font-size: 0.95rem;
}
.stats {
display: flex;
gap: 2.5rem;
white-space: nowrap;
}
.stats div {
display: flex;
flex-direction: column;
align-items: end;
}
.stats strong {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.06em;
}
.stats span,
.facts dt {
color: var(--site-muted);
font-size: 0.69rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.grid {
display: grid;
gap: 1rem;
margin-bottom: 1rem;
}
.grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid > .panel {
margin-bottom: 0;
}
.panel {
border: 1px solid var(--site-border);
background: var(--site-surface);
border-radius: 8px;
padding: 1.2rem;
margin-bottom: 1rem;
min-width: 0;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem 1rem;
margin-bottom: 1rem;
}
.muted,
.note,
.coverage {
color: var(--site-muted);
font-size: 0.75rem;
}
.note {
margin-top: 0.75rem;
}
.empty {
color: var(--site-muted);
}
.facts {
display: flex;
gap: 2rem;
margin-top: 0.5rem;
}
.facts div {
display: flex;
flex-direction: column;
}
.facts dd {
font-size: 1.1rem;
font-weight: 600;
}
.coverage {
margin-top: 2rem;
display: grid;
gap: 0.4rem;
}
.coverage a {
text-decoration: underline;
}
@media (max-width: 900px) {
.intro {
flex-direction: column;
align-items: start;
}
.stats div {
align-items: start;
}
.grid.two {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -18,12 +18,13 @@ import {
invalid,
optionalText,
parseTransactionImport,
planImport,
positiveId,
textField,
utilityKind,
validDate
} from '#lib/server/money-tracker.js';
import { and, asc, desc, eq } from 'drizzle-orm';
import { and, asc, desc, eq, gte, inArray } from 'drizzle-orm';
import { error, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@@ -48,7 +49,14 @@ export const load: PageServerLoad = async (event) => {
.where(eq(moneyAccounts.userId, userId))
.orderBy(asc(moneyAccounts.name)),
db
.select()
.select({
id: moneyTransactions.id,
accountId: moneyTransactions.accountId,
date: moneyTransactions.date,
description: moneyTransactions.description,
amountCents: moneyTransactions.amountCents,
source: moneyTransactions.source
})
.from(moneyTransactions)
.where(eq(moneyTransactions.userId, userId))
.orderBy(asc(moneyTransactions.date), asc(moneyTransactions.id)),
@@ -452,18 +460,18 @@ export const actions: Actions = {
const file = form.get('file');
if (!(file instanceof File) || !file.size || file.size > 2_000_000)
return invalid('Choose a CSV or TSV file under 2 MB.');
const chosenId = positiveId(form.get('accountId'));
const accounts = await db.select().from(moneyAccounts).where(eq(moneyAccounts.userId, user.id));
const chosen = chosenId ? accounts.find((account) => account.id === chosenId) : undefined;
if (chosenId && !chosen) return invalid('Choose an account.');
let parsed: ImportedTransaction[];
try {
parsed = parseTransactionImport(await file.text());
parsed = parseTransactionImport(await file.text(), chosen?.name);
} catch (cause) {
return invalid(cause instanceof Error ? cause.message : 'Invalid CSV.');
}
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]));
const result = await db.transaction(async (tx) => {
const ids = new Map(accounts.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
@@ -472,18 +480,50 @@ export const actions: Actions = {
.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
}))
const rows = parsed.map((row) => ({
...row,
accountId: ids.get(row.account.toLowerCase())!
}));
const accountIds = [...new Set(rows.map((row) => row.accountId))];
const existing = await tx
.select({
id: moneyTransactions.id,
accountId: moneyTransactions.accountId,
date: moneyTransactions.date,
amountCents: moneyTransactions.amountCents,
externalId: moneyTransactions.externalId
})
.from(moneyTransactions)
.where(
and(
eq(moneyTransactions.userId, user.id),
inArray(moneyTransactions.accountId, accountIds),
gte(moneyTransactions.date, addDays(rows[0].date, -1))
)
);
const { insert, duplicates } = planImport(rows, existing);
for (let i = 0; i < insert.length; i += 500) {
await tx
.insert(moneyTransactions)
.values(
insert.slice(i, i + 500).map((row) => ({
userId: user.id,
accountId: row.accountId,
date: row.date,
description: row.description,
amountCents: row.amountCents,
externalId: row.externalId,
source: 'csv'
}))
)
.onConflictDoNothing();
}
return { imported: insert.length, duplicates };
});
return { imported: parsed.length };
return result;
}
};
function addDays(date: string, days: number) {
return new Date(Date.parse(date + 'T00:00:00Z') + days * 86_400_000).toISOString().slice(0, 10);
}

View File

@@ -16,6 +16,9 @@
import Upload from 'lucide-svelte/icons/upload';
import Download from 'lucide-svelte/icons/download';
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
import { Badge } from '#lib/components/ui/badge/index.js';
import { resolve } from '$app/paths';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import * as Tabs from '#lib/components/ui/tabs/index.js';
@@ -68,6 +71,8 @@
let pageNumber = $state(1);
let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity');
let dialogOpen = $state(false);
let importAccountId = $state('');
let importNotice = $state('');
let actualTransactions = $derived(data.transactions.filter((item) => item.date <= data.today));
let futureTransactions = $derived(data.transactions.filter((item) => item.date > data.today));
let balances = $derived(accountBalances(data.accounts, actualTransactions));
@@ -280,6 +285,9 @@
</div>
</section>
<QuickAdd accounts={data.accounts} transactions={data.transactions} today={data.today} />
{#if importNotice}<p class="mb-4 text-sm" role="status">{importNotice}</p>{/if}
<div class="dashboard">
<section class="panel span-8">
<div class="panel-heading">
@@ -380,9 +388,13 @@
<h2>Transactions</h2>
<p>{filtered.length} of {data.transactions.length} entries</p>
</div>
<Button variant="outline" onclick={exportCsv} disabled={!data.transactions.length}
><Download size={16} /> Export CSV</Button
>
<div class="header-actions">
<Button variant="outline" href={resolve('/projects/money-tracker/transactions')}
>All transactions</Button
><Button variant="outline" onclick={exportCsv} disabled={!data.transactions.length}
><Download size={16} /> Export CSV</Button
>
</div>
</div>
<div class="filters">
<label class="sr-only" for="transaction-search">Search transactions</label><Input
@@ -430,7 +442,10 @@
><tbody
>{#each shown as row (row.id)}<tr class:future={row.date > data.today}
><td class="nowrap">{dateLabel(row.date)}</td><td class="strong"
>{row.description}</td
>{row.description}{#if row.source !== 'manual'}
<Badge variant="secondary" class="ml-2"
>{row.source === 'enablebanking' ? 'bank' : row.source}</Badge
>{/if}</td
><td class="nowrap"
><span class="account-cell"
>{@render swatch(row.accountId)}{accountName(row.accountId)}</span
@@ -743,10 +758,11 @@
{:else if mode === 'import'}
<p class="dialog-copy">
Upload your CSV or tab-separated export with <code>date, account, description, amount</code>
columns. Dates like <code>Fri 1 Aug 2025</code> and amounts like <code>-€1,234.50</code> are supported.
columns, or a Revolut statement export (only completed EUR rows are imported, fees included).
Dates like <code>Fri 1 Aug 2025</code> and amounts like <code>-€1,234.50</code> are supported.
Empty rows are skipped, and entries appear in date order. “Start balance” rows import as transactions,
so leave those accounts’ opening balances at zero. New account names are created automatically.
Importing a file twice adds duplicate transactions.
Rows already imported, or already entered with the same amount within a day, are skipped.
</p>
<form
method="POST"
@@ -756,7 +772,11 @@
async ({ result, update }) => {
await update();
showActionError = result.type === 'failure';
if (result.type === 'success') dialogOpen = false;
if (result.type === 'success') {
dialogOpen = false;
const done = result.data as { imported?: number; duplicates?: number } | undefined;
importNotice = `Imported ${done?.imported ?? 0} transactions; skipped ${done?.duplicates ?? 0} duplicates.`;
}
}}
>
<label class="field"
@@ -767,6 +787,16 @@
required
/></label
>
<label class="field"
>Account (for files without an account column, like Revolut statements)<NativeSelect
name="accountId"
bind:value={importAccountId}
><option value="">From the file</option
>{#each data.accounts as account (account.id)}<option value={String(account.id)}
>{account.name}</option
>{/each}</NativeSelect
></label
>
<div class="dialog-footer">
<Button type="button" class="button secondary" onclick={() => (dialogOpen = false)}
>Cancel</Button

View File

@@ -0,0 +1,18 @@
import { requireAdmin } from '#lib/server/authorization.js';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
// Enable Banking redirects here after Revolut consent. The code is single-use and only
// shown to the admin, who pastes it into scripts/bank-link.ts on the server.
export const load: PageServerLoad = async (event) => {
if (!event.locals.user)
redirect(303, '/login?returnTo=' + encodeURIComponent(event.url.pathname + event.url.search));
requireAdmin(event);
const params = event.url.searchParams;
return {
code: params.get('code')?.slice(0, 500) ?? '',
error: params.get('error')?.slice(0, 200) ?? '',
errorDescription: params.get('error_description')?.slice(0, 500) ?? '',
fullUrl: event.url.href
};
};

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { Button } from '#lib/components/ui/button/index.js';
import * as Card from '#lib/components/ui/card/index.js';
import * as Alert from '#lib/components/ui/alert/index.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let copied = $state(false);
async function copy() {
await navigator.clipboard.writeText(data.fullUrl);
copied = true;
}
</script>
<svelte:head
><title>Bank connection · Money tracker</title><meta
name="robots"
content="noindex"
/></svelte:head
>
<section class="mx-auto max-w-xl space-y-6 p-6">
<h1 class="text-2xl font-bold">Bank connection</h1>
{#if data.error}
<Alert.Root variant="destructive"
><Alert.Title>Revolut did not grant access</Alert.Title><Alert.Description
>{data.error}{data.errorDescription ? `: ${data.errorDescription}` : ''}. Run
<code>scripts/bank-link.ts</code> again to retry.</Alert.Description
></Alert.Root
>
{:else if data.code}
<Card.Root
><Card.Header
><Card.Title>Almost done</Card.Title><Card.Description
>Copy this address and paste it into the waiting <code>bank-link</code> prompt. The code is
single-use and expires within minutes.</Card.Description
></Card.Header
><Card.Content class="space-y-3"
><code class="block break-all rounded bg-muted p-3 text-xs">{data.fullUrl}</code><Button
onclick={copy}>{copied ? 'Copied' : 'Copy address'}</Button
></Card.Content
></Card.Root
>
{:else}
<p>
No authorisation code in this link. Start a connection with <code>scripts/bank-link.ts</code>.
</p>
{/if}
<p>
<a class="underline" href={resolve('/projects/money-tracker')}>Back to the money tracker</a>
</p>
</section>

View File

@@ -0,0 +1,114 @@
import { db } from '#lib/server/db/index.js';
import { moneyAccounts, moneyTransactions } from '#lib/server/db/schema/index.js';
import { requireAdmin } from '#lib/server/authorization.js';
import { positiveId, validDate } from '#lib/server/money-tracker.js';
import {
and,
asc,
count,
desc,
eq,
gt,
gte,
ilike,
inArray,
isNotNull,
lt,
lte,
or,
sql,
sum,
type SQL
} from 'drizzle-orm';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const PAGE_SIZE = 50;
const sources = ['manual', 'csv', 'enablebanking', 'linked'] as const;
const sorts = ['newest', 'oldest', 'largest', 'smallest'] as const;
export const load: PageServerLoad = async (event) => {
if (!event.locals.user)
redirect(303, '/login?returnTo=' + encodeURIComponent(event.url.pathname + event.url.search));
const user = requireAdmin(event);
const params = event.url.searchParams;
const q = (params.get('q') ?? '').trim().slice(0, 100);
const accountIds = params
.getAll('account')
.map(positiveId)
.filter((id): id is number => id !== null);
const source = sources.find((item) => item === params.get('source')) ?? '';
const type = params.get('type') === 'in' ? 'in' : params.get('type') === 'out' ? 'out' : '';
const from = validDate(params.get('from')) ?? '';
const to = validDate(params.get('to')) ?? '';
const sort = sorts.find((item) => item === params.get('sort')) ?? 'newest';
const requestedPage = positiveId(params.get('page')) ?? 1;
const t = moneyTransactions;
const filters: (SQL | undefined)[] = [eq(t.userId, user.id)];
if (q) {
const pattern = '%' + q.replace(/[\\%_]/g, (c) => '\\' + c) + '%';
filters.push(or(ilike(t.description, pattern), ilike(t.counterparty, pattern)));
}
if (accountIds.length) filters.push(inArray(t.accountId, accountIds));
if (source === 'linked') filters.push(and(eq(t.source, 'manual'), isNotNull(t.externalId)));
else if (source) filters.push(eq(t.source, source));
if (type === 'in') filters.push(gt(t.amountCents, 0));
if (type === 'out') filters.push(lt(t.amountCents, 0));
if (from) filters.push(gte(t.date, from));
if (to) filters.push(lte(t.date, to));
const where = and(...filters);
const [accounts, [totals]] = await Promise.all([
db
.select({
id: moneyAccounts.id,
name: moneyAccounts.name,
color: moneyAccounts.color,
scope: moneyAccounts.scope
})
.from(moneyAccounts)
.where(eq(moneyAccounts.userId, user.id))
.orderBy(asc(moneyAccounts.name)),
db
.select({ count: count(), sum: sum(t.amountCents) })
.from(t)
.where(where)
]);
const pages = Math.max(1, Math.ceil(totals.count / PAGE_SIZE));
const page = Math.min(requestedPage, pages);
const order =
sort === 'oldest'
? [asc(t.date), asc(t.id)]
: sort === 'largest'
? [desc(t.amountCents), desc(t.date)]
: sort === 'smallest'
? [asc(t.amountCents), desc(t.date)]
: [desc(t.date), desc(t.id)];
const rows = await db
.select({
id: t.id,
date: t.date,
description: t.description,
counterparty: t.counterparty,
amountCents: t.amountCents,
accountId: t.accountId,
source: t.source,
linked: sql<boolean>`${t.externalId} is not null`
})
.from(t)
.where(where)
.orderBy(...order)
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE);
return {
accounts,
rows,
total: totals.count,
sumCents: Number(totals.sum ?? 0),
page,
pages,
filters: { q, accountIds, source, type, from, to, sort }
};
};

View File

@@ -0,0 +1,205 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { Badge } from '#lib/components/ui/badge/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import * as Table from '#lib/components/ui/table/index.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
const sourceLabels: Record<string, string> = {
manual: 'Manual',
csv: 'CSV',
enablebanking: 'Bank'
};
let names = $derived(new Map(data.accounts.map((account) => [account.id, account.name])));
function dateLabel(value: string) {
return new Date(value + 'T12:00:00Z').toLocaleDateString('en-NL', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC'
});
}
function href(changes: Record<string, string | number | null>) {
const params = new SvelteURLSearchParams(page.url.searchParams);
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === '') params.delete(key);
else params.set(key, String(value));
}
const query = params.toString();
return resolve('/projects/money-tracker/transactions') + (query ? '?' + query : '');
}
function submit(event: SubmitEvent) {
event.preventDefault();
const form = new FormData(event.currentTarget as HTMLFormElement);
const params = new SvelteURLSearchParams();
for (const [key, value] of form)
if (typeof value === 'string' && value) params.append(key, value);
const query = params.toString();
goto(resolve('/projects/money-tracker/transactions') + (query ? '?' + query : ''), {
keepFocus: true,
noScroll: true
});
}
function autoSubmit(event: Event) {
(event.currentTarget as HTMLElement).closest('form')?.requestSubmit();
}
</script>
<svelte:head><title>Transactions · Money tracker</title></svelte:head>
<div class="mx-auto max-w-screen-xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
<header class="flex flex-wrap items-end justify-between gap-4">
<div>
<a
class="inline-flex items-center gap-1 text-sm opacity-70 hover:opacity-100"
href={resolve('/projects/money-tracker')}><ArrowLeft size={14} /> Money tracker</a
>
<h1 class="text-2xl font-bold">Transactions</h1>
<p class="text-sm opacity-70">
{data.total.toLocaleString('en-NL')} matching · net
<span class:text-green-600={data.sumCents > 0} class:text-red-600={data.sumCents < 0}
>{euro.format(data.sumCents / 100)}</span
>
</p>
</div>
</header>
<form class="grid gap-3 sm:grid-cols-2 lg:grid-cols-6" method="GET" onsubmit={submit}>
<label class="space-y-1 text-sm lg:col-span-2"
><span>Search</span><Input
type="search"
name="q"
placeholder="Description or counterparty"
value={data.filters.q}
/></label
>
<label class="space-y-1 text-sm"
><span>Account</span><NativeSelect
name="account"
value={data.filters.accountIds.length === 1 ? String(data.filters.accountIds[0]) : ''}
onchange={autoSubmit}
><option value="">All accounts</option>{#each data.accounts as account (account.id)}<option
value={String(account.id)}>{account.name}</option
>{/each}</NativeSelect
></label
>
<label class="space-y-1 text-sm"
><span>Source</span><NativeSelect
name="source"
value={data.filters.source}
onchange={autoSubmit}
><option value="">Any source</option><option value="manual">Manual</option><option
value="linked">Manual, matched to bank</option
><option value="csv">CSV import</option><option value="enablebanking">Bank sync</option
></NativeSelect
></label
>
<label class="space-y-1 text-sm"
><span>Type</span><NativeSelect name="type" value={data.filters.type} onchange={autoSubmit}
><option value="">In and out</option><option value="out">Expenses</option><option value="in"
>Income</option
></NativeSelect
></label
>
<label class="space-y-1 text-sm"
><span>Sort</span><NativeSelect name="sort" value={data.filters.sort} onchange={autoSubmit}
><option value="newest">Newest first</option><option value="oldest">Oldest first</option
><option value="largest">Largest income</option><option value="smallest"
>Largest expense</option
></NativeSelect
></label
>
<label class="space-y-1 text-sm"
><span>From</span><Input
type="date"
name="from"
value={data.filters.from}
onchange={autoSubmit}
/></label
>
<label class="space-y-1 text-sm"
><span>To</span><Input
type="date"
name="to"
value={data.filters.to}
onchange={autoSubmit}
/></label
>
<div class="flex items-end gap-2">
<Button type="submit">Apply</Button><Button
variant="ghost"
href={resolve('/projects/money-tracker/transactions')}>Reset</Button
>
</div>
</form>
<Table.Root>
<Table.Header
><Table.Row
><Table.Head>Date</Table.Head><Table.Head>Description</Table.Head><Table.Head
>Account</Table.Head
><Table.Head>Source</Table.Head><Table.Head class="text-right">Amount</Table.Head
></Table.Row
></Table.Header
>
<Table.Body>
{#each data.rows as row (row.id)}
<Table.Row>
<Table.Cell class="whitespace-nowrap">{dateLabel(row.date)}</Table.Cell>
<Table.Cell
><span class="font-medium">{row.description}</span
>{#if row.counterparty && row.counterparty !== row.description}<span
class="block text-xs opacity-60">{row.counterparty}</span
>{/if}</Table.Cell
>
<Table.Cell class="whitespace-nowrap"
>{names.get(row.accountId ?? 0) ?? 'Unassigned'}</Table.Cell
>
<Table.Cell
><Badge variant={row.source === 'manual' ? 'outline' : 'secondary'}
>{sourceLabels[row.source] ?? row.source}</Badge
>{#if row.source === 'manual' && row.linked}<Badge
variant="ghost"
title="Matched to a bank transaction">✓ bank</Badge
>{/if}</Table.Cell
>
<Table.Cell
class={'text-right tabular-nums ' +
(row.amountCents < 0 ? 'text-red-600' : 'text-green-600')}
>{euro.format(row.amountCents / 100)}</Table.Cell
>
</Table.Row>
{:else}
<Table.Row
><Table.Cell colspan={5} class="py-8 text-center opacity-70"
>No matching transactions.</Table.Cell
></Table.Row
>
{/each}
</Table.Body>
</Table.Root>
{#if data.pages > 1}
<nav class="flex items-center justify-between text-sm" aria-label="Pagination">
<span>Page {data.page} of {data.pages}</span>
<div class="flex gap-2">
{#if data.page > 1}<Button variant="outline" href={href({ page: data.page - 1 })}
>Previous</Button
>{/if}
{#if data.page < data.pages}<Button variant="outline" href={href({ page: data.page + 1 })}
>Next</Button
>{/if}
</div>
</nav>
{/if}
</div>

View File

@@ -18,6 +18,7 @@ const staticPaths = [
'/projects/game-tracker/osu',
'/projects/game-tracker/pokemon/locations',
'/projects/animaldex',
'/projects/energy',
'/projects/derby',
'/projects/spotify-beatmap-finder',
'/projects/world-opinions',

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { resolve } from '$app/paths';
</script>
<svelte:head><title>Terms · stanrunge.dev</title></svelte:head>
<article class="mx-auto max-w-2xl space-y-4 px-6 py-10 leading-relaxed">
<h1 class="text-2xl font-bold">Terms of use</h1>
<p class="text-sm opacity-70">Last updated 24 October 2026</p>
<p>
stanrunge.dev is a personal, non-commercial website by Stan Runge. By using it you agree to
these terms.
</p>
<h2 class="pt-2 text-lg font-semibold">Use of the site</h2>
<p>
The projects and tools here are provided as-is, for fun and personal use, without warranty of
any kind. They may change or disappear at any time. Don't misuse the site: no attempts to break
in, overload it, scrape it aggressively, or use it for anything unlawful.
</p>
<h2 class="pt-2 text-lg font-semibold">Accounts</h2>
<p>
You're responsible for activity on your account. Accounts that abuse the site may be removed.
You can ask for your account to be deleted at any time.
</p>
<h2 class="pt-2 text-lg font-semibold">Money tracker</h2>
<p>
The money tracker is a private tool for the site owner and household. Its bank connection is
read-only (account information via Enable Banking) and nothing shown in it is financial advice.
</p>
<h2 class="pt-2 text-lg font-semibold">Liability</h2>
<p>
To the extent the law allows, the site owner is not liable for any loss arising from use of the
site. Dutch law applies.
</p>
<p>See also the <a class="underline" href={resolve('/privacy')}>privacy page</a>.</p>
</article>

17
tests/energy.test.ts Normal file
View File

@@ -0,0 +1,17 @@
import { expect, test } from '@playwright/test';
test('energy tracker renders its charts and is listed under trackers', async ({ page }) => {
await page.goto('/projects/energy');
await expect(page.getByRole('heading', { level: 1, name: 'Energy' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Generation and consumption' })).toBeVisible();
await expect(page.locator('canvas').first()).toBeVisible();
for (const range of ['day', 'month', '12 months', 'all years']) {
await page.getByRole('tab', { name: range, exact: true }).click();
await expect(page.getByRole('tab', { name: range, exact: true })).toHaveAttribute(
'data-state',
'active'
);
}
await page.goto('/trackers');
await expect(page.locator('a[href="/projects/energy"]').first()).toBeVisible();
});