Compare commits
9 Commits
books
...
money-sync
| Author | SHA1 | Date | |
|---|---|---|---|
| e4de2dcec3 | |||
| 00c1e8b1e7 | |||
| 9a1b21ec39 | |||
| b1ce715ec7 | |||
| 064d41595f | |||
| fa2456b99e | |||
| 8aa689af64 | |||
| 8275f1fb22 | |||
| 0cf5d44062 |
@@ -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
|
||||
|
||||
1
bun.lock
1
bun.lock
@@ -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",
|
||||
|
||||
13
deploy/systemd/money-sync.service
Normal file
13
deploy/systemd/money-sync.service
Normal 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
|
||||
10
deploy/systemd/money-sync.timer
Normal file
10
deploy/systemd/money-sync.timer
Normal 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
|
||||
@@ -10,7 +10,18 @@ export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/lib/server/db/schema/index.ts',
|
||||
out: './drizzle',
|
||||
schemaFilter: ['auth', 'money', 'house', 'ref', 'games', 'cs', 'animaldex', 'site', 'meta'],
|
||||
schemaFilter: [
|
||||
'auth',
|
||||
'money',
|
||||
'house',
|
||||
'ref',
|
||||
'games',
|
||||
'cs',
|
||||
'animaldex',
|
||||
'site',
|
||||
'meta',
|
||||
'health'
|
||||
],
|
||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||
verbose: true,
|
||||
strict: true,
|
||||
|
||||
148
drizzle/20261024002120_health/migration.sql
Normal file
148
drizzle/20261024002120_health/migration.sql
Normal file
@@ -0,0 +1,148 @@
|
||||
CREATE SCHEMA "health";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "health"."appointments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"date" date NOT NULL,
|
||||
"type" text DEFAULT 'appointment' NOT NULL,
|
||||
"provider" text,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "appointments_type_check" CHECK ("type" IN ('appointment','checkup','condition','test','procedure','other')),
|
||||
CONSTRAINT "appointments_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "health"."body_measurements" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"date" date NOT NULL,
|
||||
"weight_kg" numeric(5,2),
|
||||
"waist_cm" numeric(5,1),
|
||||
"body_fat_pct" numeric(4,1),
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "body_measurements_any_check" CHECK ("weight_kg" IS NOT NULL OR "waist_cm" IS NOT NULL OR "body_fat_pct" IS NOT NULL),
|
||||
CONSTRAINT "body_measurements_weight_check" CHECK ("weight_kg" IS NULL OR "weight_kg" BETWEEN 20 AND 400),
|
||||
CONSTRAINT "body_measurements_waist_check" CHECK ("waist_cm" IS NULL OR "waist_cm" BETWEEN 30 AND 250),
|
||||
CONSTRAINT "body_measurements_fat_check" CHECK ("body_fat_pct" IS NULL OR "body_fat_pct" BETWEEN 1 AND 75),
|
||||
CONSTRAINT "body_measurements_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "health"."medications" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"name" text NOT NULL,
|
||||
"kind" text DEFAULT 'medication' NOT NULL,
|
||||
"dose" text,
|
||||
"frequency" text,
|
||||
"start_date" date,
|
||||
"stop_date" date,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "medications_name_check" CHECK (length(btrim("name")) > 0),
|
||||
CONSTRAINT "medications_kind_check" CHECK ("kind" IN ('medication','supplement')),
|
||||
CONSTRAINT "medications_dates_check" CHECK ("stop_date" IS NULL OR "start_date" IS NULL OR "stop_date" >= "start_date")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "health"."nutrition_daily" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"date" date NOT NULL CONSTRAINT "nutrition_daily_date_key" UNIQUE,
|
||||
"kcal" integer,
|
||||
"protein_g" numeric(5,1),
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "nutrition_daily_kcal_check" CHECK ("kcal" IS NULL OR "kcal" BETWEEN 0 AND 20000),
|
||||
CONSTRAINT "nutrition_daily_protein_check" CHECK ("protein_g" IS NULL OR "protein_g" BETWEEN 0 AND 1000),
|
||||
CONSTRAINT "nutrition_daily_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "health"."sleep" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"date" date NOT NULL CONSTRAINT "sleep_date_key" UNIQUE,
|
||||
"bedtime" timestamp with time zone NOT NULL,
|
||||
"wake_time" timestamp with time zone NOT NULL,
|
||||
"hours" numeric(4,2) GENERATED ALWAYS AS (round((extract(epoch from (wake_time - bedtime)) / 3600)::numeric, 2)) STORED,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "sleep_order_check" CHECK ("wake_time" > "bedtime"),
|
||||
CONSTRAINT "sleep_length_check" CHECK ("wake_time" - "bedtime" <= interval '24 hours'),
|
||||
CONSTRAINT "sleep_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "health"."vaccinations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"vaccine" text NOT NULL,
|
||||
"target" text,
|
||||
"dose_no" integer,
|
||||
"date" date NOT NULL,
|
||||
"batch_lot" text,
|
||||
"provider" text,
|
||||
"country" text,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "vaccinations_vaccine_check" CHECK (length(btrim("vaccine")) > 0),
|
||||
CONSTRAINT "vaccinations_dose_no_check" CHECK ("dose_no" IS NULL OR "dose_no" BETWEEN 1 AND 20),
|
||||
CONSTRAINT "vaccinations_country_check" CHECK ("country" IS NULL OR "country" ~ '^[A-Z]{2}$'),
|
||||
CONSTRAINT "vaccinations_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "health"."workouts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"date" date NOT NULL,
|
||||
"session_id" uuid,
|
||||
"exercise" text NOT NULL,
|
||||
"sets" integer,
|
||||
"reps" integer,
|
||||
"load_kg" numeric(6,2),
|
||||
"rpe" numeric(3,1),
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "workouts_exercise_check" CHECK (length(btrim("exercise")) > 0),
|
||||
CONSTRAINT "workouts_sets_check" CHECK ("sets" IS NULL OR "sets" BETWEEN 1 AND 100),
|
||||
CONSTRAINT "workouts_reps_check" CHECK ("reps" IS NULL OR "reps" BETWEEN 0 AND 1000),
|
||||
CONSTRAINT "workouts_load_check" CHECK ("load_kg" IS NULL OR "load_kg" BETWEEN 0 AND 1000),
|
||||
CONSTRAINT "workouts_rpe_check" CHECK ("rpe" IS NULL OR "rpe" BETWEEN 1 AND 10),
|
||||
CONSTRAINT "workouts_date_check" CHECK ("date" >= DATE '1900-01-01')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "appointments_date_idx" ON "health"."appointments" ("date");--> statement-breakpoint
|
||||
CREATE INDEX "body_measurements_date_idx" ON "health"."body_measurements" ("date");--> statement-breakpoint
|
||||
CREATE INDEX "medications_start_date_idx" ON "health"."medications" ("start_date");--> statement-breakpoint
|
||||
CREATE INDEX "medications_stop_date_idx" ON "health"."medications" ("stop_date");--> statement-breakpoint
|
||||
CREATE INDEX "vaccinations_date_idx" ON "health"."vaccinations" ("date");--> statement-breakpoint
|
||||
CREATE INDEX "workouts_date_idx" ON "health"."workouts" ("date");--> statement-breakpoint
|
||||
CREATE INDEX "workouts_session_idx" ON "health"."workouts" ("session_id");
|
||||
-- Hand-written below this line (not generated by drizzle-kit).
|
||||
-- updated_at triggers, same as every other core table (07_triggers_meta_grants.sql).
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||
FOREACH t IN ARRAY ARRAY['vaccinations','body_measurements','workouts','nutrition_daily','sleep','medications','appointments'] LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON health.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||
END LOOP;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Privacy: health data is readable/writable by the app role only. Unlike the other core
|
||||
-- schemas it is deliberately NOT granted to personal_ro, and it is not registered in
|
||||
-- meta.tables (so the generic list/table GUI never shows it).
|
||||
-- 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.
|
||||
REVOKE ALL ON SCHEMA health FROM PUBLIC;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA health TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA health TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA health
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA health
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
11583
drizzle/20261024002120_health/snapshot.json
Normal file
11583
drizzle/20261024002120_health/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
65
drizzle/20261024003809_money_bank_sync/migration.sql
Normal file
65
drizzle/20261024003809_money_bank_sync/migration.sql
Normal 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 $$;
|
||||
11543
drizzle/20261024003809_money_bank_sync/snapshot.json
Normal file
11543
drizzle/20261024003809_money_bank_sync/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
112
scripts/bank-link.ts
Normal 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
48
scripts/bank-sync.ts
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
72
scripts/health-import.ts
Normal file
72
scripts/health-import.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Import health data (JSON or CSV) into the private `health` schema.
|
||||
*
|
||||
* bun scripts/health-import.ts data.json # {"vaccinations":[...],"sleep":[...]}
|
||||
* bun scripts/health-import.ts --table vaccinations rivm.csv # CSV (header row) or a JSON array
|
||||
* bun scripts/health-import.ts --table sleep - < sleep.csv # stdin
|
||||
* add --dry-run to validate without writing
|
||||
*
|
||||
* Column names are the snake_case DB columns (see src/lib/health/tables.ts). Rows with an
|
||||
* `id` update that row; nutrition_daily and sleep upsert on `date`; everything else is
|
||||
* inserted (importing the same file twice duplicates those rows). All-or-nothing: if any
|
||||
* row is invalid, nothing is written. Uses DB_URL (the app role on prod, personal_dev on dev).
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import { parseCsv, toPayload, validatePayload, writeRows } from '../src/lib/server/health-import';
|
||||
|
||||
let dryRun = false;
|
||||
let table: string | undefined;
|
||||
const files: string[] = [];
|
||||
for (let i = 2; i < process.argv.length; i++) {
|
||||
const arg = process.argv[i];
|
||||
if (arg === '--dry-run') dryRun = true;
|
||||
else if (arg === '--table') table = process.argv[++i];
|
||||
else if (arg.startsWith('--table=')) table = arg.slice(8);
|
||||
else files.push(arg);
|
||||
}
|
||||
if (!files.length) {
|
||||
console.error(
|
||||
'usage: bun scripts/health-import.ts [--table <name>] [--dry-run] <file.json|file.csv|->...'
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const read = (file: string) => (file === '-' ? Bun.stdin.text() : readFile(file, 'utf8'));
|
||||
const merged: Record<string, unknown[]> = {};
|
||||
try {
|
||||
for (const file of files) {
|
||||
const text = await read(file);
|
||||
const csv = file.toLowerCase().endsWith('.csv') || (file === '-' && !/^\s*[[{]/.test(text));
|
||||
const payload = toPayload(csv ? parseCsv(text) : JSON.parse(text), table);
|
||||
for (const [t, rows] of Object.entries(payload)) (merged[t] ??= []).push(...(rows ?? []));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e instanceof Error ? e.message : e);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { clean, errors } = validatePayload(toPayload(merged));
|
||||
if (errors.length) {
|
||||
for (const e of errors.slice(0, 50)) console.error(`${e.table} row ${e.row}: ${e.error}`);
|
||||
console.error(`${errors.length} invalid row(s); nothing imported.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const summary = Object.entries(clean)
|
||||
.map(([t, r]) => `${t}: ${r.length}`)
|
||||
.join(', ');
|
||||
if (dryRun) {
|
||||
console.log(`Valid (dry run, nothing written): ${summary}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||
const client = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const counts = await writeRows(drizzle({ client }), clean);
|
||||
for (const [t, c] of Object.entries(counts))
|
||||
console.log(`${t}: ${c.inserted} inserted, ${c.updated} updated`);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Handle } from '@sveltejs/kit/hooks';
|
||||
import { building } from '$app/env';
|
||||
import { auth } from '#lib/server/auth.js';
|
||||
import { isOwner, isOwnerOnlyPath } from '#lib/server/owner.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Match the endpoint path here: the dev proxy may terminate HTTPS before Vite.
|
||||
@@ -9,5 +11,22 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
const session = building ? null : await auth.api.getSession({ headers: event.request.headers });
|
||||
event.locals.user = session?.user ?? null;
|
||||
event.locals.session = session?.session ?? null;
|
||||
return resolve(event);
|
||||
// Owner-only areas (health tracker). Page loads, __data.json and the import endpoint
|
||||
// 404 for non-owners in the route itself (layout load / endpoint), which renders the
|
||||
// same 404 page as an unknown URL. As a backstop, any other method (form actions,
|
||||
// which skip layout loads) is refused here before a route runs.
|
||||
const ownerOnly = isOwnerOnlyPath(event.url.pathname);
|
||||
if (
|
||||
ownerOnly &&
|
||||
!isOwner(event) &&
|
||||
event.request.method !== 'GET' &&
|
||||
event.request.method !== 'HEAD'
|
||||
)
|
||||
error(404, 'Not Found');
|
||||
const response = await resolve(event);
|
||||
if (ownerOnly) {
|
||||
response.headers.set('x-robots-tag', 'noindex, nofollow, noarchive');
|
||||
response.headers.set('cache-control', 'private, no-store');
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
|
||||
import FolderKanban from 'lucide-svelte/icons/folder-kanban';
|
||||
import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
|
||||
import HeartPulse from 'lucide-svelte/icons/heart-pulse';
|
||||
import House from 'lucide-svelte/icons/house';
|
||||
import Globe2 from 'lucide-svelte/icons/globe-2';
|
||||
import Languages from 'lucide-svelte/icons/languages';
|
||||
@@ -34,7 +35,8 @@
|
||||
'money tracker': Wallet,
|
||||
'cs prices': CandlestickChart,
|
||||
'house tracker': House,
|
||||
lists: ListChecks
|
||||
lists: ListChecks,
|
||||
health: HeartPulse
|
||||
};
|
||||
|
||||
const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban);
|
||||
|
||||
104
src/lib/energy/EnergyChart.svelte
Normal file
104
src/lib/energy/EnergyChart.svelte
Normal 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>
|
||||
42
src/lib/energy/data.test.ts
Normal file
42
src/lib/energy/data.test.ts
Normal 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
195
src/lib/energy/data.ts
Normal 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);
|
||||
}
|
||||
118
src/lib/health/BodyChart.svelte
Normal file
118
src/lib/health/BodyChart.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
// Weight + waist trend. Same lazy-loaded ECharts setup as the CS prices chart.
|
||||
import { onMount } from 'svelte';
|
||||
import type { ECharts } from 'echarts/core';
|
||||
|
||||
export type BodyPoint = { date: string; weightKg: number | null; waistCm: number | null };
|
||||
let { points }: { points: BodyPoint[] } = $props();
|
||||
let element: HTMLDivElement;
|
||||
let chart: ECharts | undefined;
|
||||
let ready = $state(false);
|
||||
|
||||
const series = $derived([
|
||||
{
|
||||
name: 'Weight (kg)',
|
||||
yAxisIndex: 0,
|
||||
data: points.filter((p) => p.weightKg !== null).map((p) => [Date.parse(p.date), p.weightKg])
|
||||
},
|
||||
{
|
||||
name: 'Waist (cm)',
|
||||
yAxisIndex: 1,
|
||||
data: points.filter((p) => p.waistCm !== null).map((p) => [Date.parse(p.date), p.waistCm])
|
||||
}
|
||||
]);
|
||||
|
||||
function update() {
|
||||
if (!chart) return;
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const foreground = style.getPropertyValue('--site-text').trim();
|
||||
const muted = style.getPropertyValue('--site-muted').trim();
|
||||
const border = style.getPropertyValue('--site-border').trim();
|
||||
const dark = style.getPropertyValue('--site-bg').trim() === '#000';
|
||||
const axis = (name: string) => ({
|
||||
type: 'value' as const,
|
||||
name,
|
||||
scale: true,
|
||||
nameTextStyle: { color: muted },
|
||||
axisLabel: { color: muted },
|
||||
splitLine: { lineStyle: { color: border } }
|
||||
});
|
||||
chart.setOption(
|
||||
{
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 48, right: 48, top: 36, bottom: 28 },
|
||||
legend: { top: 0, textStyle: { color: muted } },
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
axisLine: { lineStyle: { color: border } },
|
||||
axisLabel: { color: muted },
|
||||
splitLine: { show: false }
|
||||
},
|
||||
yAxis: [axis('kg'), { ...axis('cm'), splitLine: { show: false } }],
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: style.getPropertyValue('--site-surface').trim(),
|
||||
borderColor: border,
|
||||
textStyle: { color: foreground }
|
||||
},
|
||||
series: series.map((s, i) => ({
|
||||
...s,
|
||||
type: 'line',
|
||||
showSymbol: s.data.length < 60,
|
||||
symbolSize: 5,
|
||||
itemStyle: { color: `hsl(${i ? 200 : 25} 70% ${dark ? '65%' : '40%'})` },
|
||||
lineStyle: { width: 2 }
|
||||
}))
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
let resize: ResizeObserver | undefined;
|
||||
let theme: MutationObserver | undefined;
|
||||
import('echarts/core').then(async (echarts) => {
|
||||
const [
|
||||
{ LineChart },
|
||||
{ GridComponent, TooltipComponent, LegendComponent },
|
||||
{ CanvasRenderer }
|
||||
] = await Promise.all([
|
||||
import('echarts/charts'),
|
||||
import('echarts/components'),
|
||||
import('echarts/renderers')
|
||||
]);
|
||||
if (disposed) return;
|
||||
echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, 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(() => {
|
||||
if (ready) update();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="chart" bind:this={element} role="img" aria-label="Weight and waist trend"></div>
|
||||
|
||||
<style>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
}
|
||||
</style>
|
||||
129
src/lib/health/tables.ts
Normal file
129
src/lib/health/tables.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// Client-safe description of the health tables: used by the forms on /trackers/health
|
||||
// and by the server-side validator in #lib/server/health-import.ts. Keys are the
|
||||
// snake_case column names, which are also the import (JSON/CSV) field names.
|
||||
|
||||
export type FieldType =
|
||||
'date' | 'text' | 'textarea' | 'integer' | 'number' | 'datetime' | 'select' | 'uuid';
|
||||
|
||||
export type Field = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
required?: boolean;
|
||||
options?: readonly string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export const medicationKinds = ['medication', 'supplement'] as const;
|
||||
export const appointmentTypes = [
|
||||
'appointment',
|
||||
'checkup',
|
||||
'condition',
|
||||
'test',
|
||||
'procedure',
|
||||
'other'
|
||||
] as const;
|
||||
|
||||
const notes: Field = { key: 'notes', label: 'Notes', type: 'textarea', maxLength: 4000 };
|
||||
const date: Field = { key: 'date', label: 'Date', type: 'date', required: true };
|
||||
|
||||
export const healthTables = {
|
||||
vaccinations: {
|
||||
label: 'Vaccinations',
|
||||
fields: [
|
||||
{ key: 'vaccine', label: 'Vaccine', type: 'text', required: true, maxLength: 200 },
|
||||
{ key: 'target', label: 'Disease / target', type: 'text', maxLength: 200 },
|
||||
{ key: 'dose_no', label: 'Dose no.', type: 'integer', min: 1, max: 20 },
|
||||
date,
|
||||
{ key: 'batch_lot', label: 'Batch / lot', type: 'text', maxLength: 100 },
|
||||
{ key: 'provider', label: 'Provider', type: 'text', maxLength: 200 },
|
||||
{
|
||||
key: 'country',
|
||||
label: 'Country (ISO code)',
|
||||
type: 'text',
|
||||
maxLength: 2,
|
||||
placeholder: 'NL'
|
||||
},
|
||||
notes
|
||||
]
|
||||
},
|
||||
body_measurements: {
|
||||
label: 'Body measurements',
|
||||
fields: [
|
||||
date,
|
||||
{ key: 'weight_kg', label: 'Weight (kg)', type: 'number', min: 20, max: 400, step: 0.01 },
|
||||
{
|
||||
key: 'waist_cm',
|
||||
label: 'Waist at navel (cm)',
|
||||
type: 'number',
|
||||
min: 30,
|
||||
max: 250,
|
||||
step: 0.1
|
||||
},
|
||||
{ key: 'body_fat_pct', label: 'Body fat (%)', type: 'number', min: 1, max: 75, step: 0.1 },
|
||||
notes
|
||||
]
|
||||
},
|
||||
workouts: {
|
||||
label: 'Workouts',
|
||||
fields: [
|
||||
date,
|
||||
{ key: 'exercise', label: 'Exercise', type: 'text', required: true, maxLength: 200 },
|
||||
{ key: 'sets', label: 'Sets', type: 'integer', min: 1, max: 100 },
|
||||
{ key: 'reps', label: 'Reps (per set)', type: 'integer', min: 0, max: 1000 },
|
||||
{ key: 'load_kg', label: 'Load (kg)', type: 'number', min: 0, max: 1000, step: 0.25 },
|
||||
{ key: 'rpe', label: 'RPE', type: 'number', min: 1, max: 10, step: 0.5 },
|
||||
{ key: 'session_id', label: 'Session id (optional)', type: 'uuid' },
|
||||
notes
|
||||
]
|
||||
},
|
||||
nutrition_daily: {
|
||||
label: 'Nutrition',
|
||||
fields: [
|
||||
date,
|
||||
{ key: 'kcal', label: 'kcal', type: 'integer', min: 0, max: 20000 },
|
||||
{ key: 'protein_g', label: 'Protein (g)', type: 'number', min: 0, max: 1000, step: 0.1 },
|
||||
notes
|
||||
]
|
||||
},
|
||||
sleep: {
|
||||
label: 'Sleep',
|
||||
fields: [
|
||||
{ ...date, label: 'Date (wake-up day)' },
|
||||
{ key: 'bedtime', label: 'Bedtime', type: 'datetime', required: true },
|
||||
{ key: 'wake_time', label: 'Wake time', type: 'datetime', required: true },
|
||||
notes
|
||||
]
|
||||
},
|
||||
medications: {
|
||||
label: 'Medications & supplements',
|
||||
fields: [
|
||||
{ key: 'name', label: 'Name', type: 'text', required: true, maxLength: 200 },
|
||||
{ key: 'kind', label: 'Kind', type: 'select', options: medicationKinds },
|
||||
{ key: 'dose', label: 'Dose', type: 'text', maxLength: 200, placeholder: '1 mg' },
|
||||
{ key: 'frequency', label: 'Frequency', type: 'text', maxLength: 200, placeholder: 'daily' },
|
||||
{ key: 'start_date', label: 'Start date', type: 'date' },
|
||||
{ key: 'stop_date', label: 'Stop date', type: 'date' },
|
||||
notes
|
||||
]
|
||||
},
|
||||
|
||||
appointments: {
|
||||
label: 'Appointments & conditions',
|
||||
fields: [
|
||||
date,
|
||||
{ key: 'type', label: 'Type', type: 'select', options: appointmentTypes },
|
||||
{ key: 'provider', label: 'Provider', type: 'text', maxLength: 200 },
|
||||
notes
|
||||
]
|
||||
}
|
||||
} as const satisfies Record<string, { label: string; fields: readonly Field[] }>;
|
||||
|
||||
export type HealthTable = keyof typeof healthTables;
|
||||
export const healthTableNames = Object.keys(healthTables) as HealthTable[];
|
||||
export const isHealthTable = (value: unknown): value is HealthTable =>
|
||||
typeof value === 'string' && Object.hasOwn(healthTables, value);
|
||||
269
src/lib/money-tracker/QuickAdd.svelte
Normal file
269
src/lib/money-tracker/QuickAdd.svelte
Normal 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>
|
||||
24
src/lib/money-tracker/quick-add.test.ts
Normal file
24
src/lib/money-tracker/quick-add.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
73
src/lib/money-tracker/quick-add.ts
Normal file
73
src/lib/money-tracker/quick-add.ts
Normal 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));
|
||||
}
|
||||
@@ -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',
|
||||
@@ -71,3 +77,19 @@ export const trackers = [
|
||||
external: false
|
||||
}
|
||||
] as const;
|
||||
|
||||
// Owner-only trackers: shown in the header, home page and /trackers only to the logged-in
|
||||
// owner (layout data `isOwner`), never in the sitemap. Their routes 404 for everyone else.
|
||||
export const privateTrackers = [
|
||||
{
|
||||
name: 'health',
|
||||
subtitle: 'private: body, workouts, sleep, meds and vaccinations',
|
||||
href: '/trackers/health',
|
||||
external: false
|
||||
}
|
||||
] as const;
|
||||
|
||||
type Tracker = { name: string; subtitle: string; href: string; external: boolean };
|
||||
export function visibleTrackers(isOwner: boolean): readonly Tracker[] {
|
||||
return isOwner ? [...trackers, ...privateTrackers] : trackers;
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
|
||||
176
src/lib/server/bank-sync.test.ts
Normal file
176
src/lib/server/bank-sync.test.ts
Normal 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
411
src/lib/server/bank-sync.ts
Normal 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;
|
||||
}
|
||||
179
src/lib/server/db/schema/health.ts
Normal file
179
src/lib/server/db/schema/health.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { check, date, index, integer, numeric, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { healthSchema } from './schemas';
|
||||
import { timestamps, timestamptz } from './helpers';
|
||||
|
||||
// Private health log (owner only, see requireOwner). No user_id: there is exactly one
|
||||
// owner, and the whole schema is readable by personal_app only (no personal_ro grant).
|
||||
// Numerics use mode 'number' so the app gets plain numbers instead of strings.
|
||||
|
||||
const pk = () => uuid('id').primaryKey().defaultRandom();
|
||||
const day = (name = 'date') => date(name, { mode: 'string' });
|
||||
const sane = (column: string) => sql.raw(`"${column}" >= DATE '1900-01-01'`);
|
||||
|
||||
export const healthVaccinations = healthSchema.table(
|
||||
'vaccinations',
|
||||
{
|
||||
id: pk(),
|
||||
vaccine: text('vaccine').notNull(),
|
||||
target: text('target'), // disease(s) it protects against, e.g. "DTP", "COVID-19"
|
||||
doseNo: integer('dose_no'),
|
||||
date: day().notNull(),
|
||||
batchLot: text('batch_lot'),
|
||||
provider: text('provider'), // GGD, GP, travel clinic...
|
||||
country: text('country'), // ISO 3166-1 alpha-2, e.g. NL
|
||||
notes: text('notes'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('vaccinations_date_idx').on(t.date),
|
||||
check('vaccinations_vaccine_check', sql`length(btrim(${t.vaccine})) > 0`),
|
||||
check('vaccinations_dose_no_check', sql`${t.doseNo} IS NULL OR ${t.doseNo} BETWEEN 1 AND 20`),
|
||||
check('vaccinations_country_check', sql`${t.country} IS NULL OR ${t.country} ~ '^[A-Z]{2}$'`),
|
||||
check('vaccinations_date_check', sane('date'))
|
||||
]
|
||||
);
|
||||
|
||||
export const healthBodyMeasurements = healthSchema.table(
|
||||
'body_measurements',
|
||||
{
|
||||
id: pk(),
|
||||
date: day().notNull(),
|
||||
weightKg: numeric('weight_kg', { precision: 5, scale: 2, mode: 'number' }),
|
||||
waistCm: numeric('waist_cm', { precision: 5, scale: 1, mode: 'number' }), // at the navel
|
||||
bodyFatPct: numeric('body_fat_pct', { precision: 4, scale: 1, mode: 'number' }),
|
||||
notes: text('notes'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('body_measurements_date_idx').on(t.date),
|
||||
check(
|
||||
'body_measurements_any_check',
|
||||
sql`${t.weightKg} IS NOT NULL OR ${t.waistCm} IS NOT NULL OR ${t.bodyFatPct} IS NOT NULL`
|
||||
),
|
||||
check(
|
||||
'body_measurements_weight_check',
|
||||
sql`${t.weightKg} IS NULL OR ${t.weightKg} BETWEEN 20 AND 400`
|
||||
),
|
||||
check(
|
||||
'body_measurements_waist_check',
|
||||
sql`${t.waistCm} IS NULL OR ${t.waistCm} BETWEEN 30 AND 250`
|
||||
),
|
||||
check(
|
||||
'body_measurements_fat_check',
|
||||
sql`${t.bodyFatPct} IS NULL OR ${t.bodyFatPct} BETWEEN 1 AND 75`
|
||||
),
|
||||
check('body_measurements_date_check', sane('date'))
|
||||
]
|
||||
);
|
||||
|
||||
export const healthWorkouts = healthSchema.table(
|
||||
'workouts',
|
||||
{
|
||||
id: pk(),
|
||||
date: day().notNull(),
|
||||
sessionId: uuid('session_id'), // optional: groups the exercises of one session
|
||||
exercise: text('exercise').notNull(),
|
||||
sets: integer('sets'),
|
||||
reps: integer('reps'), // per set
|
||||
loadKg: numeric('load_kg', { precision: 6, scale: 2, mode: 'number' }),
|
||||
rpe: numeric('rpe', { precision: 3, scale: 1, mode: 'number' }),
|
||||
notes: text('notes'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('workouts_date_idx').on(t.date),
|
||||
index('workouts_session_idx').on(t.sessionId),
|
||||
check('workouts_exercise_check', sql`length(btrim(${t.exercise})) > 0`),
|
||||
check('workouts_sets_check', sql`${t.sets} IS NULL OR ${t.sets} BETWEEN 1 AND 100`),
|
||||
check('workouts_reps_check', sql`${t.reps} IS NULL OR ${t.reps} BETWEEN 0 AND 1000`),
|
||||
check('workouts_load_check', sql`${t.loadKg} IS NULL OR ${t.loadKg} BETWEEN 0 AND 1000`),
|
||||
check('workouts_rpe_check', sql`${t.rpe} IS NULL OR ${t.rpe} BETWEEN 1 AND 10`),
|
||||
check('workouts_date_check', sane('date'))
|
||||
]
|
||||
);
|
||||
|
||||
export const healthNutritionDaily = healthSchema.table(
|
||||
'nutrition_daily',
|
||||
{
|
||||
id: pk(),
|
||||
date: day().notNull().unique('nutrition_daily_date_key'),
|
||||
kcal: integer('kcal'),
|
||||
proteinG: numeric('protein_g', { precision: 5, scale: 1, mode: 'number' }),
|
||||
notes: text('notes'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
check('nutrition_daily_kcal_check', sql`${t.kcal} IS NULL OR ${t.kcal} BETWEEN 0 AND 20000`),
|
||||
check(
|
||||
'nutrition_daily_protein_check',
|
||||
sql`${t.proteinG} IS NULL OR ${t.proteinG} BETWEEN 0 AND 1000`
|
||||
),
|
||||
check('nutrition_daily_date_check', sane('date'))
|
||||
]
|
||||
);
|
||||
|
||||
export const healthSleep = healthSchema.table(
|
||||
'sleep',
|
||||
{
|
||||
id: pk(),
|
||||
date: day().notNull().unique('sleep_date_key'), // the wake-up date of the night
|
||||
bedtime: timestamptz('bedtime').notNull(),
|
||||
wakeTime: timestamp('wake_time', { withTimezone: true }).notNull(),
|
||||
hours: numeric('hours', { precision: 4, scale: 2, mode: 'number' }).generatedAlwaysAs(
|
||||
sql`round((extract(epoch from (wake_time - bedtime)) / 3600)::numeric, 2)`
|
||||
),
|
||||
notes: text('notes'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
check('sleep_order_check', sql`${t.wakeTime} > ${t.bedtime}`),
|
||||
check('sleep_length_check', sql`${t.wakeTime} - ${t.bedtime} <= interval '24 hours'`),
|
||||
check('sleep_date_check', sane('date'))
|
||||
]
|
||||
);
|
||||
|
||||
export const healthMedications = healthSchema.table(
|
||||
'medications',
|
||||
{
|
||||
id: pk(),
|
||||
name: text('name').notNull(),
|
||||
kind: text('kind').notNull().default('medication'),
|
||||
dose: text('dose'), // free text: "1 mg", "5% solution, 1 ml"
|
||||
frequency: text('frequency'), // free text: "daily", "2x daily", "as needed"
|
||||
startDate: day('start_date'),
|
||||
stopDate: day('stop_date'), // null = still taking it
|
||||
notes: text('notes'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('medications_start_date_idx').on(t.startDate),
|
||||
index('medications_stop_date_idx').on(t.stopDate),
|
||||
check('medications_name_check', sql`length(btrim(${t.name})) > 0`),
|
||||
check('medications_kind_check', sql`${t.kind} IN ('medication','supplement')`),
|
||||
check(
|
||||
'medications_dates_check',
|
||||
sql`${t.stopDate} IS NULL OR ${t.startDate} IS NULL OR ${t.stopDate} >= ${t.startDate}`
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export const healthAppointments = healthSchema.table(
|
||||
'appointments',
|
||||
{
|
||||
id: pk(),
|
||||
date: day().notNull(),
|
||||
type: text('type').notNull().default('appointment'),
|
||||
provider: text('provider'),
|
||||
notes: text('notes'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('appointments_date_idx').on(t.date),
|
||||
check(
|
||||
'appointments_type_check',
|
||||
sql`${t.type} IN ('appointment','checkup','condition','test','procedure','other')`
|
||||
),
|
||||
check('appointments_date_check', sane('date'))
|
||||
]
|
||||
);
|
||||
@@ -12,3 +12,4 @@ export * from './schemas';
|
||||
export * from './money-tracker';
|
||||
export * from './cs-prices';
|
||||
export * from './house-tracker';
|
||||
export * from './health';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -12,3 +12,4 @@ export const csSchema = pgSchema('cs');
|
||||
export const animaldexSchema = pgSchema('animaldex');
|
||||
export const siteSchema = pgSchema('site');
|
||||
export const metaSchema = pgSchema('meta');
|
||||
export const healthSchema = pgSchema('health');
|
||||
|
||||
260
src/lib/server/enable-banking.ts
Normal file
260
src/lib/server/enable-banking.ts
Normal 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
121
src/lib/server/energy.ts
Normal 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>>;
|
||||
52
src/lib/server/health-import.test.ts
Normal file
52
src/lib/server/health-import.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { amsterdamTime, cleanRow, parseCsv, toPayload, validatePayload } from './health-import';
|
||||
|
||||
describe('health import', () => {
|
||||
it('parses CSV with quotes and semicolons', () => {
|
||||
expect(parseCsv('date;kcal;notes\r\n2026-01-02;2500;"a; ""b"""\n')).toEqual([
|
||||
{ date: '2026-01-02', kcal: '2500', notes: 'a; "b"' }
|
||||
]);
|
||||
});
|
||||
it('validates and camel-cases rows', () => {
|
||||
const r = cleanRow('body_measurements', {
|
||||
date: '2026-01-02',
|
||||
weight_kg: '80,5',
|
||||
waist_cm: ''
|
||||
});
|
||||
expect(r).toEqual({
|
||||
ok: true,
|
||||
row: {
|
||||
id: undefined,
|
||||
values: { date: '2026-01-02', weightKg: 80.5, waistCm: null, bodyFatPct: null, notes: null }
|
||||
}
|
||||
});
|
||||
expect(cleanRow('body_measurements', { date: '2026-01-02' }).ok).toBe(false);
|
||||
expect(cleanRow('workouts', { date: '2026-01-02', exercise: 'squat', rpe: 11 }).ok).toBe(false);
|
||||
expect(cleanRow('workouts', { date: '2026-01-02', exercise: 'squat', bogus: 1 }).ok).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
cleanRow('vaccinations', { vaccine: 'X', date: '2026-01-02', country: 'nl' })
|
||||
).toMatchObject({ ok: true, row: { values: { country: 'NL' } } });
|
||||
});
|
||||
it('resolves bare sleep times in Amsterdam time', () => {
|
||||
const r = cleanRow('sleep', { date: '2026-07-02', bedtime: '23:30', wake_time: '07:15' });
|
||||
expect(r.ok && (r.row.values.bedtime as Date).toISOString()).toBe('2026-07-01T21:30:00.000Z');
|
||||
expect(r.ok && (r.row.values.wakeTime as Date).toISOString()).toBe('2026-07-02T05:15:00.000Z');
|
||||
expect(amsterdamTime('2026-01-15', '12:00')?.toISOString()).toBe('2026-01-15T11:00:00.000Z');
|
||||
const after = cleanRow('sleep', { date: '2026-07-02', bedtime: '00:40', wake_time: '08:00' });
|
||||
expect(after.ok && (after.row.values.bedtime as Date).toISOString()).toBe(
|
||||
'2026-07-01T22:40:00.000Z'
|
||||
);
|
||||
});
|
||||
it('reports errors per row and table', () => {
|
||||
const { errors } = validatePayload(
|
||||
toPayload({ nutrition_daily: [{ date: 'x' }, { date: '2026-01-01', kcal: 2000 }] })
|
||||
);
|
||||
expect(errors).toEqual([
|
||||
{ table: 'nutrition_daily', row: 1, error: expect.stringContaining('date') }
|
||||
]);
|
||||
expect(() => toPayload([{}])).toThrow();
|
||||
expect(() => toPayload({ nope: [] })).toThrow();
|
||||
});
|
||||
});
|
||||
349
src/lib/server/health-import.ts
Normal file
349
src/lib/server/health-import.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
// Validation + import for the private health tables. Shared by the owner-only import
|
||||
// endpoint (/trackers/health/import), the page's add/edit forms and
|
||||
// scripts/health-import.ts, so it must not import anything from SvelteKit.
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { PgAsyncDatabase } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
healthAppointments,
|
||||
healthBodyMeasurements,
|
||||
healthMedications,
|
||||
healthNutritionDaily,
|
||||
healthSleep,
|
||||
healthVaccinations,
|
||||
healthWorkouts
|
||||
} from './db/schema/health';
|
||||
import { healthTables, isHealthTable, type Field, type HealthTable } from '../health/tables';
|
||||
|
||||
export const drizzleTables = {
|
||||
vaccinations: healthVaccinations,
|
||||
body_measurements: healthBodyMeasurements,
|
||||
workouts: healthWorkouts,
|
||||
nutrition_daily: healthNutritionDaily,
|
||||
sleep: healthSleep,
|
||||
medications: healthMedications,
|
||||
appointments: healthAppointments
|
||||
} as const;
|
||||
|
||||
/** Tables with one row per day: importing the same date again updates that day. */
|
||||
const uniqueByDate = new Set<HealthTable>(['nutrition_daily', 'sleep']);
|
||||
/** Columns that exports may contain but imports ignore. */
|
||||
const ignored = new Set(['hours', 'created_at', 'updated_at']);
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
export const TIME_ZONE = 'Europe/Amsterdam';
|
||||
export const MAX_ROWS = 5000;
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
export type CleanRow = { id?: string; values: Record<string, unknown> };
|
||||
export type RowResult = { ok: true; row: CleanRow } | { ok: false; error: string };
|
||||
|
||||
const camel = (key: string) => key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
|
||||
export function validDate(raw: string): string | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null;
|
||||
const d = new Date(`${raw}T12:00:00Z`);
|
||||
return !Number.isNaN(d.valueOf()) && d.toISOString().slice(0, 10) === raw && raw >= '1900-01-01'
|
||||
? raw
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Offset of Europe/Amsterdam (ms) at a given UTC instant. */
|
||||
function zoneOffset(utc: number): number {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: TIME_ZONE,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).formatToParts(new Date(utc));
|
||||
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value);
|
||||
return (
|
||||
Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second')) -
|
||||
utc
|
||||
);
|
||||
}
|
||||
|
||||
/** Wall-clock time in Amsterdam ("YYYY-MM-DD", "HH:MM[:SS]") to a Date. */
|
||||
export function amsterdamTime(day: string, time: string): Date | null {
|
||||
const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(time);
|
||||
if (!m || !validDate(day)) return null;
|
||||
const [h, min, s] = [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)];
|
||||
if (h > 23 || min > 59 || s > 59) return null;
|
||||
const [y, mo, d] = day.split('-').map(Number);
|
||||
const wall = Date.UTC(y, mo - 1, d, h, min, s);
|
||||
let utc = wall - zoneOffset(wall);
|
||||
utc = wall - zoneOffset(utc); // second pass settles DST edges
|
||||
return new Date(utc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Datetimes: ISO with offset ("2026-10-24T07:10:00+02:00", "...Z"), local Amsterdam
|
||||
* ("2026-10-24T07:10" / "2026-10-24 07:10", which is what <input type=datetime-local> sends),
|
||||
* or a bare "HH:MM" resolved against the row's date by the caller.
|
||||
*/
|
||||
function parseDateTime(raw: string): Date | 'time' | null {
|
||||
if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(raw)) return 'time';
|
||||
const local = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}(?::\d{2})?)(?:\.\d+)?$/.exec(raw);
|
||||
if (local) return amsterdamTime(local[1], local[2]);
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i.test(raw)) {
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.valueOf()) ? null : d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cleanField(field: Field, value: unknown): { value: unknown } | { error: string } {
|
||||
if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
|
||||
return field.required ? { error: `${field.key} is required` } : { value: null };
|
||||
}
|
||||
const raw = typeof value === 'string' ? value.trim() : value;
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
case 'textarea': {
|
||||
if (typeof raw !== 'string' && typeof raw !== 'number')
|
||||
return { error: `${field.key} must be text` };
|
||||
const text = String(raw);
|
||||
if (field.maxLength && text.length > field.maxLength)
|
||||
return { error: `${field.key} is longer than ${field.maxLength} characters` };
|
||||
return { value: text };
|
||||
}
|
||||
case 'integer':
|
||||
case 'number': {
|
||||
const n = typeof raw === 'number' ? raw : Number(String(raw).replace(',', '.'));
|
||||
if (!Number.isFinite(n)) return { error: `${field.key} must be a number` };
|
||||
if (field.type === 'integer' && !Number.isInteger(n))
|
||||
return { error: `${field.key} must be a whole number` };
|
||||
if ((field.min !== undefined && n < field.min) || (field.max !== undefined && n > field.max))
|
||||
return { error: `${field.key} must be between ${field.min} and ${field.max}` };
|
||||
return { value: n };
|
||||
}
|
||||
case 'date': {
|
||||
const d = typeof raw === 'string' ? validDate(raw) : null;
|
||||
return d
|
||||
? { value: d }
|
||||
: { error: `${field.key} must be a date (YYYY-MM-DD, 1900 or later)` };
|
||||
}
|
||||
case 'datetime': {
|
||||
const d = typeof raw === 'string' ? parseDateTime(raw) : null;
|
||||
return d ? { value: d } : { error: `${field.key} must be an ISO datetime or HH:MM` };
|
||||
}
|
||||
case 'select':
|
||||
return field.options?.includes(String(raw))
|
||||
? { value: String(raw) }
|
||||
: { error: `${field.key} must be one of ${field.options?.join(', ')}` };
|
||||
case 'uuid':
|
||||
return typeof raw === 'string' && UUID.test(raw)
|
||||
? { value: raw.toLowerCase() }
|
||||
: { error: `${field.key} must be a UUID` };
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one row (snake_case keys) for a table. Unknown keys are rejected. */
|
||||
export function cleanRow(table: HealthTable, input: Row): RowResult {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input))
|
||||
return { ok: false, error: 'row must be an object' };
|
||||
const fields: readonly Field[] = healthTables[table].fields;
|
||||
const known = new Set(fields.map((f) => f.key));
|
||||
for (const key of Object.keys(input))
|
||||
if (key !== 'id' && !known.has(key) && !ignored.has(key))
|
||||
return { ok: false, error: `unknown column "${key}"` };
|
||||
let id: string | undefined;
|
||||
if (input.id !== undefined && input.id !== null && input.id !== '') {
|
||||
if (typeof input.id !== 'string' || !UUID.test(input.id))
|
||||
return { ok: false, error: 'id must be a UUID' };
|
||||
id = input.id.toLowerCase();
|
||||
}
|
||||
const v: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const result = cleanField(field, input[field.key]);
|
||||
if ('error' in result) return { ok: false, error: result.error };
|
||||
v[field.key] = result.value;
|
||||
}
|
||||
|
||||
// Table-specific rules (mirrors the CHECK constraints, with friendlier messages).
|
||||
if (table === 'vaccinations' && typeof v.country === 'string') {
|
||||
const country = v.country.toUpperCase();
|
||||
v.country = country;
|
||||
if (!/^[A-Z]{2}$/.test(country))
|
||||
return { ok: false, error: 'country must be a 2-letter ISO code, e.g. NL' };
|
||||
}
|
||||
if (
|
||||
table === 'body_measurements' &&
|
||||
v.weight_kg === null &&
|
||||
v.waist_cm === null &&
|
||||
v.body_fat_pct === null
|
||||
)
|
||||
return { ok: false, error: 'give at least one of weight_kg, waist_cm, body_fat_pct' };
|
||||
if (table === 'medications') {
|
||||
v.kind ??= 'medication';
|
||||
if (v.start_date && v.stop_date && String(v.stop_date) < String(v.start_date))
|
||||
return { ok: false, error: 'stop_date is before start_date' };
|
||||
}
|
||||
if (table === 'appointments') v.type ??= 'appointment';
|
||||
if (table === 'sleep') {
|
||||
const day = String(v.date);
|
||||
const wakeRaw = String(input.wake_time).trim();
|
||||
const bedRaw = String(input.bedtime).trim();
|
||||
if (v.wake_time === 'time') v.wake_time = amsterdamTime(day, wakeRaw);
|
||||
if (v.bedtime === 'time') {
|
||||
// A bare bedtime is on the wake-up day if it is before the wake time, else the evening before.
|
||||
const sameDay = amsterdamTime(day, bedRaw);
|
||||
const wake = v.wake_time as Date | null;
|
||||
if (sameDay && wake && sameDay < wake) v.bedtime = sameDay;
|
||||
else {
|
||||
const prev = new Date(`${day}T12:00:00Z`);
|
||||
prev.setUTCDate(prev.getUTCDate() - 1);
|
||||
v.bedtime = amsterdamTime(prev.toISOString().slice(0, 10), bedRaw);
|
||||
}
|
||||
}
|
||||
const bed = v.bedtime as Date | null;
|
||||
const wake = v.wake_time as Date | null;
|
||||
if (!bed || !wake) return { ok: false, error: 'invalid bedtime / wake_time' };
|
||||
if (wake <= bed) return { ok: false, error: 'wake_time must be after bedtime' };
|
||||
if (wake.getTime() - bed.getTime() > 24 * 3600e3)
|
||||
return { ok: false, error: 'sleep longer than 24 hours' };
|
||||
} else {
|
||||
for (const key of Object.keys(v))
|
||||
if (v[key] === 'time') return { ok: false, error: `${key} needs a full datetime` };
|
||||
}
|
||||
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(v)) values[camel(key)] = value;
|
||||
return { ok: true, row: { id, values } };
|
||||
}
|
||||
|
||||
/** Minimal RFC 4180 CSV parser: header row, quoted fields, CRLF, BOM. Semicolons allowed as delimiter. */
|
||||
export function parseCsv(text: string): Row[] {
|
||||
const src = text.replace(/^\uFEFF/, '');
|
||||
const firstLine = src.split(/\r?\n/, 1)[0] ?? '';
|
||||
const delimiter =
|
||||
(firstLine.match(/;/g)?.length ?? 0) > (firstLine.match(/,/g)?.length ?? 0) ? ';' : ',';
|
||||
const records: string[][] = [];
|
||||
let record: string[] = [];
|
||||
let field = '';
|
||||
let quoted = false;
|
||||
const endField = () => {
|
||||
record.push(field);
|
||||
field = '';
|
||||
};
|
||||
const endRecord = () => {
|
||||
endField();
|
||||
records.push(record);
|
||||
record = [];
|
||||
};
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
const c = src[i];
|
||||
if (quoted) {
|
||||
if (c === '"' && src[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else if (c === '"') quoted = false;
|
||||
else field += c;
|
||||
} else if (c === '"' && field === '') quoted = true;
|
||||
else if (c === delimiter) endField();
|
||||
else if (c === '\n' || c === '\r') {
|
||||
if (c === '\r' && src[i + 1] === '\n') i++;
|
||||
endRecord();
|
||||
} else field += c;
|
||||
}
|
||||
if (quoted) throw new Error('CSV has an unterminated quoted field');
|
||||
if (field !== '' || record.length) endRecord();
|
||||
const rows = records.filter((r) => r.some((cell) => cell.trim() !== ''));
|
||||
if (!rows.length) return [];
|
||||
const header = rows[0].map((h) => h.trim().toLowerCase());
|
||||
if (new Set(header).size !== header.length) throw new Error('CSV header has duplicate columns');
|
||||
return rows.slice(1).map((cells, line) => {
|
||||
if (cells.length > header.length)
|
||||
throw new Error(`CSV row ${line + 2} has more cells than the header`);
|
||||
return Object.fromEntries(header.map((h, i) => [h, cells[i] ?? '']));
|
||||
});
|
||||
}
|
||||
|
||||
export type Payload = Partial<Record<HealthTable, Row[]>>;
|
||||
|
||||
/**
|
||||
* Accepted shapes:
|
||||
* JSON object keyed by table: { "vaccinations": [ {...}, ... ], "sleep": [ ... ] }
|
||||
* JSON array of rows + table: [ {...}, ... ] (table given separately)
|
||||
* CSV with a header row + table
|
||||
*/
|
||||
export function toPayload(body: unknown, table?: string | null): Payload {
|
||||
if (Array.isArray(body)) {
|
||||
if (!isHealthTable(table))
|
||||
throw new Error('a plain list of rows needs a table (?table= or --table)');
|
||||
return { [table]: body };
|
||||
}
|
||||
if (!body || typeof body !== 'object')
|
||||
throw new Error('expected a JSON object keyed by table, or an array of rows');
|
||||
const payload: Payload = {};
|
||||
for (const [key, rows] of Object.entries(body)) {
|
||||
if (!isHealthTable(key)) throw new Error(`unknown table "${key}"`);
|
||||
if (!Array.isArray(rows)) throw new Error(`"${key}" must be an array of rows`);
|
||||
payload[key] = rows as Row[];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export type ValidationError = { table: HealthTable; row: number; error: string };
|
||||
|
||||
export function validatePayload(payload: Payload) {
|
||||
const clean: Partial<Record<HealthTable, CleanRow[]>> = {};
|
||||
const errors: ValidationError[] = [];
|
||||
let total = 0;
|
||||
for (const [table, rows] of Object.entries(payload) as [HealthTable, Row[]][]) {
|
||||
total += rows.length;
|
||||
clean[table] = [];
|
||||
rows.forEach((row, i) => {
|
||||
const result = cleanRow(table, row);
|
||||
if (result.ok) clean[table]!.push(result.row);
|
||||
else errors.push({ table, row: i + 1, error: result.error });
|
||||
});
|
||||
}
|
||||
if (total > MAX_ROWS)
|
||||
errors.push({
|
||||
table: Object.keys(payload)[0] as HealthTable,
|
||||
row: 0,
|
||||
error: `more than ${MAX_ROWS} rows`
|
||||
});
|
||||
return { clean, errors };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Db = PgAsyncDatabase<any, any>;
|
||||
export type ImportCounts = Partial<Record<HealthTable, { inserted: number; updated: number }>>;
|
||||
|
||||
/** Insert/update rows; all-or-nothing in one transaction. Rows with an id update that row;
|
||||
* nutrition_daily and sleep upsert on date. */
|
||||
export async function writeRows(
|
||||
db: Db,
|
||||
clean: Partial<Record<HealthTable, CleanRow[]>>
|
||||
): Promise<ImportCounts> {
|
||||
return db.transaction(async (tx) => {
|
||||
const counts: ImportCounts = {};
|
||||
for (const [table, rows] of Object.entries(clean) as [HealthTable, CleanRow[]][]) {
|
||||
const target = drizzleTables[table];
|
||||
const count = { inserted: 0, updated: 0 };
|
||||
for (const { id, values } of rows) {
|
||||
const data = { ...values, ...(id ? { id } : {}) };
|
||||
const set = { ...values, updatedAt: new Date() };
|
||||
const conflictTarget = id
|
||||
? target.id
|
||||
: uniqueByDate.has(table)
|
||||
? (target as typeof healthSleep).date
|
||||
: null;
|
||||
const query = tx.insert(target).values(data as never);
|
||||
const [result] = await (
|
||||
conflictTarget
|
||||
? query.onConflictDoUpdate({ target: conflictTarget, set: set as never })
|
||||
: query
|
||||
).returning({ inserted: sql<boolean>`(xmax = 0)` });
|
||||
if (result?.inserted) count.inserted++;
|
||||
else count.updated++;
|
||||
}
|
||||
counts[table] = count;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
20
src/lib/server/owner.test.ts
Normal file
20
src/lib/server/owner.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isOwner, isOwnerOnlyPath } from './owner';
|
||||
|
||||
const event = (user: Partial<NonNullable<App.Locals['user']>> | null) =>
|
||||
({ locals: { user, session: null } }) as unknown as { locals: App.Locals };
|
||||
|
||||
describe('owner gate', () => {
|
||||
it('is only the owner account', () => {
|
||||
expect(isOwner(event(null))).toBe(false);
|
||||
expect(isOwner(event({ role: 'admin', email: 'someone@example.com' }))).toBe(false);
|
||||
expect(isOwner(event({ role: 'user', email: 'Stan@StanRunge.dev' }))).toBe(true);
|
||||
});
|
||||
it('matches owner-only paths', () => {
|
||||
expect(isOwnerOnlyPath('/trackers/health')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/health/')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/Health/import')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/healthy')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers')).toBe(false);
|
||||
});
|
||||
});
|
||||
27
src/lib/server/owner.ts
Normal file
27
src/lib/server/owner.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
// The site has exactly one owner: Stan's account (prod auth.user id tfn6OZDOpiQvMARIH65HQ2XJuaVvmGEe).
|
||||
// Owner-only data, such as the health tracker, is gated on that account's email, not on
|
||||
// a role, so other users (now or later, e.g. on a public tracking project) never see it.
|
||||
// Override with OWNER_EMAIL if the address ever changes.
|
||||
const OWNER_EMAIL = (process.env.OWNER_EMAIL || 'stan@stanrunge.dev').trim().toLowerCase();
|
||||
|
||||
type OwnerEvent = { locals: App.Locals };
|
||||
|
||||
export function isOwner(event: OwnerEvent): boolean {
|
||||
return event.locals.user?.email?.trim().toLowerCase() === OWNER_EMAIL;
|
||||
}
|
||||
|
||||
/** Owner-only routes answer 404 to everyone else, so they don't reveal that they exist. */
|
||||
export function requireOwner(event: OwnerEvent) {
|
||||
if (!isOwner(event)) error(404, 'Not Found');
|
||||
return event.locals.user!;
|
||||
}
|
||||
|
||||
/** Paths that only the owner may see (checked in hooks.server.ts before any route runs). */
|
||||
export const ownerOnlyPrefixes = ['/trackers/health'] as const;
|
||||
|
||||
export function isOwnerOnlyPath(pathname: string): boolean {
|
||||
const path = pathname.toLowerCase().replace(/\/+$/, '');
|
||||
return ownerOnlyPrefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isAdmin } from '#lib/server/authorization.js';
|
||||
import { isOwner } from '#lib/server/owner.js';
|
||||
import { dev } from '$app/env';
|
||||
import { siteEnvironment } from '#lib/title.js';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
@@ -8,6 +9,7 @@ export const load: LayoutServerLoad = (event) => ({
|
||||
? { id: event.locals.user.id, name: event.locals.user.name, image: event.locals.user.image }
|
||||
: null,
|
||||
isAdmin: isAdmin(event),
|
||||
isOwner: isOwner(event),
|
||||
editable: isAdmin(event),
|
||||
version: process.env.APP_VERSION || 'dev',
|
||||
environment
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { getSeo } from '#lib/seo.js';
|
||||
import CopyButton from '#lib/components/CopyButton.svelte';
|
||||
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
|
||||
import { projects, trackers } from '#lib/projects.js';
|
||||
import { projects, visibleTrackers } from '#lib/projects.js';
|
||||
import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
|
||||
import * as Sheet from '#lib/components/ui/sheet/index.js';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
@@ -42,13 +42,11 @@
|
||||
{ label: 'blog', href: '/blog' },
|
||||
{ label: 'lists', href: '/lists' }
|
||||
] as const;
|
||||
const menus: Record<
|
||||
string,
|
||||
{ label: string; items: readonly (typeof projects | typeof trackers)[number][] }
|
||||
> = {
|
||||
type MenuItem = { name: string; subtitle: string; href: string; external: boolean };
|
||||
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
|
||||
'/projects': { label: 'projects', items: projects },
|
||||
'/trackers': { label: 'trackers', items: trackers }
|
||||
};
|
||||
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) }
|
||||
});
|
||||
const socialLinks = [
|
||||
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
|
||||
{ icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' },
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import portrait from '#lib/assets/stan.jpg?url';
|
||||
import ProgressBar from '#lib/components/ProgressBar.svelte';
|
||||
import CatalogGrid from '#lib/components/CatalogGrid.svelte';
|
||||
import { projects, trackers } from '#lib/projects.js';
|
||||
import { projects, visibleTrackers } from '#lib/projects.js';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import Mail from 'lucide-svelte/icons/mail';
|
||||
@@ -114,7 +114,10 @@
|
||||
>trackers <ArrowRight size={20} aria-hidden="true" /></a
|
||||
>
|
||||
</h2>
|
||||
<CatalogGrid items={trackers} subtitleClass="mt-1 block text-base text-[var(--site-muted)]" />
|
||||
<CatalogGrid
|
||||
items={visibleTrackers(data.isOwner)}
|
||||
subtitleClass="mt-1 block text-base text-[var(--site-muted)]"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="lists-heading" class="pt-14 sm:pt-20">
|
||||
|
||||
39
src/routes/privacy/+page.svelte
Normal file
39
src/routes/privacy/+page.svelte
Normal 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>
|
||||
4
src/routes/projects/energy/+page.server.ts
Normal file
4
src/routes/projects/energy/+page.server.ts
Normal 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() });
|
||||
896
src/routes/projects/energy/+page.svelte
Normal file
896
src/routes/projects/energy/+page.svelte
Normal 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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
53
src/routes/projects/money-tracker/bank-callback/+page.svelte
Normal file
53
src/routes/projects/money-tracker/bank-callback/+page.svelte
Normal 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>
|
||||
114
src/routes/projects/money-tracker/transactions/+page.server.ts
Normal file
114
src/routes/projects/money-tracker/transactions/+page.server.ts
Normal 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 }
|
||||
};
|
||||
};
|
||||
205
src/routes/projects/money-tracker/transactions/+page.svelte
Normal file
205
src/routes/projects/money-tracker/transactions/+page.svelte
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
36
src/routes/terms/+page.svelte
Normal file
36
src/routes/terms/+page.svelte
Normal 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>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import CatalogGrid from '#lib/components/CatalogGrid.svelte';
|
||||
import { trackers } from '#lib/projects.js';
|
||||
import { visibleTrackers } from '#lib/projects.js';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
@@ -8,5 +10,5 @@
|
||||
<p class="mb-10 text-[var(--site-muted)]">
|
||||
Personal data I keep track of: games, money, houses, prices, animals and lists.
|
||||
</p>
|
||||
<CatalogGrid items={trackers} />
|
||||
<CatalogGrid items={visibleTrackers(data.isOwner)} />
|
||||
</div>
|
||||
|
||||
11
src/routes/trackers/health/+layout.server.ts
Normal file
11
src/routes/trackers/health/+layout.server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
// Owner only: everyone else (logged out, or any other account) gets a plain 404.
|
||||
// Form actions don't run this load, so +page.server.ts checks again in every action,
|
||||
// and hooks.server.ts blocks the whole /trackers/health prefix as a backstop.
|
||||
export const load: LayoutServerLoad = (event) => {
|
||||
requireOwner(event);
|
||||
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||
return {};
|
||||
};
|
||||
110
src/routes/trackers/health/+page.server.ts
Normal file
110
src/routes/trackers/health/+page.server.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import {
|
||||
healthAppointments,
|
||||
healthBodyMeasurements,
|
||||
healthMedications,
|
||||
healthNutritionDaily,
|
||||
healthSleep,
|
||||
healthVaccinations,
|
||||
healthWorkouts
|
||||
} from '#lib/server/db/schema/index.js';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { cleanRow, drizzleTables, TIME_ZONE, writeRows } from '#lib/server/health-import.js';
|
||||
import { healthTables, isHealthTable } from '#lib/health/tables.js';
|
||||
import { asc, desc, eq, gte, lt } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function today(): string {
|
||||
return new Intl.DateTimeFormat('en-CA', { timeZone: TIME_ZONE }).format(new Date());
|
||||
}
|
||||
function daysAgo(day: string, n: number): string {
|
||||
const d = new Date(`${day}T12:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() - n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
requireOwner(event);
|
||||
const now = today();
|
||||
const [measurements, workouts, nutrition, sleep, medications, vaccinations, upcoming, past] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(healthBodyMeasurements)
|
||||
.orderBy(asc(healthBodyMeasurements.date), asc(healthBodyMeasurements.createdAt)),
|
||||
db
|
||||
.select()
|
||||
.from(healthWorkouts)
|
||||
.orderBy(desc(healthWorkouts.date), asc(healthWorkouts.createdAt))
|
||||
.limit(30),
|
||||
db
|
||||
.select()
|
||||
.from(healthNutritionDaily)
|
||||
.where(gte(healthNutritionDaily.date, daysAgo(now, 6)))
|
||||
.orderBy(desc(healthNutritionDaily.date)),
|
||||
db
|
||||
.select()
|
||||
.from(healthSleep)
|
||||
.where(gte(healthSleep.date, daysAgo(now, 6)))
|
||||
.orderBy(desc(healthSleep.date)),
|
||||
db.select().from(healthMedications).orderBy(asc(healthMedications.name)),
|
||||
db
|
||||
.select()
|
||||
.from(healthVaccinations)
|
||||
.orderBy(desc(healthVaccinations.date), asc(healthVaccinations.vaccine)),
|
||||
db
|
||||
.select()
|
||||
.from(healthAppointments)
|
||||
.where(gte(healthAppointments.date, now))
|
||||
.orderBy(asc(healthAppointments.date)),
|
||||
db
|
||||
.select()
|
||||
.from(healthAppointments)
|
||||
.where(lt(healthAppointments.date, now))
|
||||
.orderBy(desc(healthAppointments.date))
|
||||
.limit(10)
|
||||
]);
|
||||
return {
|
||||
today: now,
|
||||
measurements,
|
||||
workouts,
|
||||
nutrition,
|
||||
sleep,
|
||||
medications,
|
||||
vaccinations,
|
||||
appointments: { upcoming, past },
|
||||
seo: { title: 'Health', description: 'Private health log.', index: false }
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async (event) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const table = form.get('table');
|
||||
if (!isHealthTable(table)) return fail(400, { message: 'Unknown table' });
|
||||
const row: Record<string, unknown> = { id: form.get('id') || undefined };
|
||||
for (const field of healthTables[table].fields) row[field.key] = form.get(field.key) ?? '';
|
||||
const result = cleanRow(table, row);
|
||||
if (!result.ok) return fail(400, { message: result.error, table });
|
||||
try {
|
||||
await writeRows(db, { [table]: [result.row] });
|
||||
} catch (e) {
|
||||
return fail(400, { message: e instanceof Error ? e.message : 'Could not save', table });
|
||||
}
|
||||
return { saved: table };
|
||||
},
|
||||
delete: async (event) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const table = form.get('table');
|
||||
const id = String(form.get('id') ?? '');
|
||||
if (!isHealthTable(table) || !UUID.test(id)) return fail(400, { message: 'Invalid entry' });
|
||||
const target = drizzleTables[table];
|
||||
await db.delete(target).where(eq(target.id, id));
|
||||
return { deleted: table };
|
||||
}
|
||||
};
|
||||
486
src/routes/trackers/health/+page.svelte
Normal file
486
src/routes/trackers/health/+page.svelte
Normal file
@@ -0,0 +1,486 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import BodyChart from '#lib/health/BodyChart.svelte';
|
||||
import {
|
||||
healthTables,
|
||||
healthTableNames,
|
||||
type Field,
|
||||
type HealthTable
|
||||
} from '#lib/health/tables.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 { Textarea } from '#lib/components/ui/textarea/index.js';
|
||||
import * as Dialog from '#lib/components/ui/dialog/index.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import Pencil from 'lucide-svelte/icons/pencil';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import Lock from 'lucide-svelte/icons/lock';
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
type AnyRow = { id: string } & Record<string, unknown>;
|
||||
let dialogOpen = $state(false);
|
||||
let editor = $state<{ table: HealthTable; row: AnyRow | null; confirmDelete: boolean }>({
|
||||
table: 'body_measurements',
|
||||
row: null,
|
||||
confirmDelete: false
|
||||
});
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const fields = $derived<readonly Field[]>(healthTables[editor.table].fields);
|
||||
const latest = $derived(data.measurements.at(-1));
|
||||
const latestWeight = $derived(data.measurements.findLast((m) => m.weightKg !== null));
|
||||
const latestWaist = $derived(data.measurements.findLast((m) => m.waistCm !== null));
|
||||
const todayNutrition = $derived(data.nutrition.find((n) => n.date === data.today));
|
||||
const activeMeds = $derived(
|
||||
data.medications.filter(
|
||||
(m) =>
|
||||
(!m.startDate || m.startDate <= data.today) && (!m.stopDate || m.stopDate >= data.today)
|
||||
)
|
||||
);
|
||||
const otherMeds = $derived(data.medications.filter((m) => !activeMeds.includes(m)));
|
||||
const sleepAverage = $derived.by(() => {
|
||||
const hours = data.sleep.map((s) => s.hours).filter((h): h is number => h !== null);
|
||||
return hours.length ? hours.reduce((a, b) => a + b, 0) / hours.length : null;
|
||||
});
|
||||
const workoutDays = $derived.by(() => {
|
||||
const days: [string, typeof data.workouts][] = [];
|
||||
for (const w of data.workouts) {
|
||||
const last = days.at(-1);
|
||||
if (last?.[0] === w.date) last[1].push(w);
|
||||
else days.push([w.date, [w]]);
|
||||
}
|
||||
return days;
|
||||
});
|
||||
|
||||
const camel = (key: string) => key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
const localTime = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: 'Europe/Amsterdam',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const clock = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Europe/Amsterdam',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const dayLabel = (day: string) =>
|
||||
new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: day.slice(0, 4) === data.today.slice(0, 4) ? undefined : 'numeric'
|
||||
});
|
||||
const num = (value: number | null | undefined, digits = 1) =>
|
||||
value === null || value === undefined ? '–' : value.toFixed(digits).replace(/\.0+$/, '');
|
||||
|
||||
function fieldValue(field: Field): string {
|
||||
const value = editor.row?.[camel(field.key)];
|
||||
if (value === null || value === undefined) {
|
||||
if (!editor.row && field.type === 'date' && field.key === 'date') return data.today;
|
||||
return '';
|
||||
}
|
||||
if (value instanceof Date) return localTime.format(value).replace(' ', 'T');
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function open(table: HealthTable, row: AnyRow | null = null, confirmDelete = false) {
|
||||
editor = { table, row, confirmDelete };
|
||||
error = null;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
const submit: SubmitFunction =
|
||||
() =>
|
||||
async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') dialogOpen = false;
|
||||
else error = String((result.type === 'failure' && result.data?.message) || 'Could not save');
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet rowActions(table: HealthTable, row: AnyRow)}
|
||||
<span class="flex shrink-0 gap-1">
|
||||
<button class="icon" type="button" aria-label="Edit" onclick={() => open(table, row)}
|
||||
><Pencil size={14} /></button
|
||||
>
|
||||
<button class="icon" type="button" aria-label="Delete" onclick={() => open(table, row, true)}
|
||||
><Trash2 size={14} /></button
|
||||
>
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet sectionHeader(title: string, table: HealthTable)}
|
||||
<header class="mb-3 flex items-center justify-between gap-2">
|
||||
<h2 class="text-lg font-semibold tracking-tight">{title}</h2>
|
||||
<button class="icon" type="button" aria-label={`Add to ${title}`} onclick={() => open(table)}
|
||||
><Plus size={16} /></button
|
||||
>
|
||||
</header>
|
||||
{/snippet}
|
||||
|
||||
{#snippet empty(text: string)}
|
||||
<p class="text-sm text-[var(--site-muted)]">{text}</p>
|
||||
{/snippet}
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">health</h1>
|
||||
<p class="mt-2 flex items-center gap-1.5 text-sm text-[var(--site-muted)]">
|
||||
<Lock size={14} aria-hidden="true" /> Private. Only you can see this page.
|
||||
</p>
|
||||
</div>
|
||||
<NativeSelect
|
||||
aria-label="Add an entry"
|
||||
value=""
|
||||
onchange={(e: Event) => {
|
||||
const select = e.currentTarget as HTMLSelectElement;
|
||||
if (select.value) open(select.value as HealthTable);
|
||||
select.value = '';
|
||||
}}
|
||||
>
|
||||
<option value="">+ add entry…</option>
|
||||
{#each healthTableNames as table (table)}<option value={table}
|
||||
>{healthTables[table].label}</option
|
||||
>{/each}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<section class="panel lg:col-span-2">
|
||||
{@render sectionHeader('Body', 'body_measurements')}
|
||||
{#if latest}
|
||||
<div class="mb-3 flex flex-wrap gap-6">
|
||||
<p>
|
||||
<span class="stat">{num(latestWeight?.weightKg, 2)}</span> kg
|
||||
<small>{latestWeight ? dayLabel(latestWeight.date) : ''}</small>
|
||||
</p>
|
||||
<p>
|
||||
<span class="stat">{num(latestWaist?.waistCm)}</span> cm waist
|
||||
<small>{latestWaist ? dayLabel(latestWaist.date) : ''}</small>
|
||||
</p>
|
||||
{#if latest.bodyFatPct !== null}<p>
|
||||
<span class="stat">{num(latest.bodyFatPct)}</span>% fat
|
||||
</p>{/if}
|
||||
</div>
|
||||
{#if data.measurements.length > 1}
|
||||
<BodyChart points={data.measurements} />
|
||||
{:else}
|
||||
{@render empty('Add a second measurement to see a trend.')}
|
||||
{/if}
|
||||
<details class="mt-3 text-sm">
|
||||
<summary class="cursor-pointer text-[var(--site-muted)]"
|
||||
>All measurements ({data.measurements.length})</summary
|
||||
>
|
||||
<ul class="mt-2 divide-y divide-[var(--site-border)]">
|
||||
{#each data.measurements.toReversed() as m (m.id)}
|
||||
<li class="flex items-center justify-between py-1.5">
|
||||
<span
|
||||
>{dayLabel(m.date)} · {num(m.weightKg, 2)} kg · {num(m.waistCm)} cm{m.bodyFatPct !==
|
||||
null
|
||||
? ` · ${num(m.bodyFatPct)}%`
|
||||
: ''}{m.notes ? ` · ${m.notes}` : ''}</span
|
||||
>
|
||||
{@render rowActions('body_measurements', m)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{:else}
|
||||
{@render empty('No measurements yet.')}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
{@render sectionHeader('Nutrition today', 'nutrition_daily')}
|
||||
{#if todayNutrition}
|
||||
<p>
|
||||
<span class="stat">{todayNutrition.kcal ?? '–'}</span> kcal ·
|
||||
<span class="stat">{num(todayNutrition.proteinG)}</span> g protein
|
||||
</p>
|
||||
{:else}
|
||||
{@render empty('Nothing logged today.')}
|
||||
{/if}
|
||||
{#if data.nutrition.length}
|
||||
<ul class="mt-3 divide-y divide-[var(--site-border)] text-sm">
|
||||
{#each data.nutrition as n (n.id)}
|
||||
<li class="flex items-center justify-between py-1.5">
|
||||
<span>{dayLabel(n.date)} · {n.kcal ?? '–'} kcal · {num(n.proteinG)} g</span>
|
||||
{@render rowActions('nutrition_daily', n)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
{@render sectionHeader('Sleep, last 7 days', 'sleep')}
|
||||
{#if data.sleep.length}
|
||||
{#if sleepAverage !== null}<p class="mb-2">
|
||||
<span class="stat">{num(sleepAverage, 1)}</span> h average
|
||||
</p>{/if}
|
||||
<ul class="divide-y divide-[var(--site-border)] text-sm">
|
||||
{#each data.sleep as s (s.id)}
|
||||
<li class="flex items-center justify-between py-1.5">
|
||||
<span
|
||||
>{dayLabel(s.date)} · {clock.format(s.bedtime)}–{clock.format(s.wakeTime)} · {num(
|
||||
s.hours,
|
||||
2
|
||||
)} h</span
|
||||
>
|
||||
{@render rowActions('sleep', s)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
{@render empty('No sleep logged this week.')}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
{@render sectionHeader('Medications & supplements', 'medications')}
|
||||
{#if activeMeds.length}
|
||||
<ul class="divide-y divide-[var(--site-border)] text-sm">
|
||||
{#each activeMeds as m (m.id)}
|
||||
<li class="flex items-center justify-between gap-2 py-1.5">
|
||||
<span
|
||||
><strong>{m.name}</strong>{m.dose ? ` · ${m.dose}` : ''}{m.frequency
|
||||
? ` · ${m.frequency}`
|
||||
: ''}
|
||||
<small>{m.kind}{m.startDate ? `, since ${dayLabel(m.startDate)}` : ''}</small></span
|
||||
>
|
||||
{@render rowActions('medications', m)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
{@render empty('No active medications or supplements.')}
|
||||
{/if}
|
||||
{#if otherMeds.length}
|
||||
<details class="mt-2 text-sm">
|
||||
<summary class="cursor-pointer text-[var(--site-muted)]"
|
||||
>Stopped or not started ({otherMeds.length})</summary
|
||||
>
|
||||
<ul class="mt-1">
|
||||
{#each otherMeds as m (m.id)}
|
||||
<li class="flex items-center justify-between py-1">
|
||||
<span>{m.name} <small>{m.startDate ?? '?'} → {m.stopDate ?? 'now'}</small></span>
|
||||
{@render rowActions('medications', m)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
{@render sectionHeader('Appointments', 'appointments')}
|
||||
{#if data.appointments.upcoming.length}
|
||||
<ul class="divide-y divide-[var(--site-border)] text-sm">
|
||||
{#each data.appointments.upcoming as a (a.id)}
|
||||
<li class="flex items-center justify-between gap-2 py-1.5">
|
||||
<span
|
||||
><strong>{dayLabel(a.date)}</strong> · {a.type}{a.provider
|
||||
? ` · ${a.provider}`
|
||||
: ''}{a.notes ? ` · ${a.notes}` : ''}</span
|
||||
>
|
||||
{@render rowActions('appointments', a)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
{@render empty('Nothing upcoming.')}
|
||||
{/if}
|
||||
{#if data.appointments.past.length}
|
||||
<details class="mt-2 text-sm">
|
||||
<summary class="cursor-pointer text-[var(--site-muted)]"
|
||||
>Recent ({data.appointments.past.length})</summary
|
||||
>
|
||||
<ul class="mt-1">
|
||||
{#each data.appointments.past as a (a.id)}
|
||||
<li class="flex items-center justify-between py-1">
|
||||
<span>{dayLabel(a.date)} · {a.type}{a.provider ? ` · ${a.provider}` : ''}</span>
|
||||
{@render rowActions('appointments', a)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel lg:col-span-2">
|
||||
{@render sectionHeader('Recent workouts', 'workouts')}
|
||||
{#if workoutDays.length}
|
||||
{#each workoutDays as [day, entries] (day)}
|
||||
<h3 class="mt-2 text-sm font-medium text-[var(--site-muted)]">{dayLabel(day)}</h3>
|
||||
<ul class="divide-y divide-[var(--site-border)] text-sm">
|
||||
{#each entries as w (w.id)}
|
||||
<li class="flex items-center justify-between gap-2 py-1.5">
|
||||
<span
|
||||
><strong>{w.exercise}</strong>{w.sets
|
||||
? ` · ${w.sets}×${w.reps ?? '?'}`
|
||||
: ''}{w.loadKg !== null ? ` @ ${num(w.loadKg, 2)} kg` : ''}{w.rpe !== null
|
||||
? ` · RPE ${num(w.rpe)}`
|
||||
: ''}{w.notes ? ` · ${w.notes}` : ''}</span
|
||||
>
|
||||
{@render rowActions('workouts', w)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/each}
|
||||
{:else}
|
||||
{@render empty('No workouts yet.')}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel lg:col-span-3">
|
||||
{@render sectionHeader('Vaccinations', 'vaccinations')}
|
||||
{#if data.vaccinations.length}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="text-[var(--site-muted)]">
|
||||
<tr
|
||||
><th>Date</th><th>Vaccine</th><th>Against</th><th>Dose</th><th>Batch</th><th
|
||||
>Provider</th
|
||||
><th>Country</th><th></th></tr
|
||||
>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.vaccinations as v (v.id)}
|
||||
<tr class="border-t border-[var(--site-border)]">
|
||||
<td>{v.date}</td><td>{v.vaccine}</td><td>{v.target ?? ''}</td><td
|
||||
>{v.doseNo ?? ''}</td
|
||||
>
|
||||
<td>{v.batchLot ?? ''}</td><td>{v.provider ?? ''}</td><td>{v.country ?? ''}</td>
|
||||
<td>{@render rowActions('vaccinations', v)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
{@render empty('No vaccinations yet. Import your Mijn RIVM record to fill this in.')}
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Content class="block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
|
||||
<Dialog.Header class="mb-4 pr-8">
|
||||
<Dialog.Title>
|
||||
{editor.confirmDelete ? 'Delete entry' : editor.row ? 'Edit' : 'Add'} · {healthTables[
|
||||
editor.table
|
||||
].label}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="sr-only">Manage a private health record.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
{#if editor.confirmDelete && editor.row}
|
||||
<form method="POST" action="?/delete" use:enhance={submit}>
|
||||
<input type="hidden" name="table" value={editor.table} />
|
||||
<input type="hidden" name="id" value={editor.row.id} />
|
||||
<p class="mb-4 text-sm">Delete this entry? This cannot be undone.</p>
|
||||
{#if error}<p class="mb-3 text-sm text-red-500" role="alert">{error}</p>{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onclick={() => (dialogOpen = false)}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button type="submit" variant="destructive">Delete</Button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<form method="POST" action="?/save" use:enhance={submit} class="grid gap-3">
|
||||
<input type="hidden" name="table" value={editor.table} />
|
||||
{#if editor.row}<input type="hidden" name="id" value={editor.row.id} />{/if}
|
||||
{#each fields as field (field.key)}
|
||||
<label class="grid gap-1 text-sm">
|
||||
<span>{field.label}{field.required ? ' *' : ''}</span>
|
||||
{#if field.type === 'textarea'}
|
||||
<Textarea
|
||||
name={field.key}
|
||||
value={fieldValue(field)}
|
||||
maxlength={field.maxLength}
|
||||
rows={2}
|
||||
/>
|
||||
{:else if field.type === 'select'}
|
||||
<NativeSelect name={field.key} value={fieldValue(field) || field.options?.[0]}>
|
||||
{#each field.options ?? [] as option (option)}<option value={option}
|
||||
>{option}</option
|
||||
>{/each}
|
||||
</NativeSelect>
|
||||
{:else}
|
||||
<Input
|
||||
name={field.key}
|
||||
value={fieldValue(field)}
|
||||
required={field.required}
|
||||
type={field.type === 'date'
|
||||
? 'date'
|
||||
: field.type === 'datetime'
|
||||
? 'datetime-local'
|
||||
: field.type === 'integer' || field.type === 'number'
|
||||
? 'number'
|
||||
: 'text'}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.type === 'integer' ? 1 : field.step}
|
||||
maxlength={field.maxLength}
|
||||
placeholder={field.placeholder}
|
||||
inputmode={field.type === 'number' ? 'decimal' : undefined}
|
||||
/>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
{#if error}<p class="text-sm text-red-500" role="alert">{error}</p>{/if}
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onclick={() => (dialogOpen = false)}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
{#if form?.message && !dialogOpen}<p class="sr-only" role="status">{form.message}</p>{/if}
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-surface);
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
.stat {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
small {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.3rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.icon:hover {
|
||||
background: color-mix(in srgb, var(--site-muted) 15%, transparent);
|
||||
color: var(--site-text);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 0.4rem 0.5rem 0.4rem 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
51
src/routes/trackers/health/import/+server.ts
Normal file
51
src/routes/trackers/health/import/+server.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { parseCsv, toPayload, validatePayload, writeRows } from '#lib/server/health-import.js';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const MAX_BYTES = 2_000_000;
|
||||
|
||||
// Owner-only import. Same formats as scripts/health-import.ts:
|
||||
// POST application/json {"vaccinations":[...], "sleep":[...]} or [...] with ?table=
|
||||
// POST text/csv header row + rows, with ?table=
|
||||
// ?dry_run=1 validate only
|
||||
// Only JSON/CSV content types are accepted, so a cross-site form can't post here
|
||||
// (browsers preflight these types, and we never send CORS headers).
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const type = (event.request.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
||||
if (type !== 'application/json' && type !== 'text/csv')
|
||||
error(415, 'Use application/json or text/csv');
|
||||
const text = await event.request.text();
|
||||
if (text.length > MAX_BYTES) error(413, 'Body too large');
|
||||
const table = event.url.searchParams.get('table');
|
||||
let payload;
|
||||
try {
|
||||
payload =
|
||||
type === 'text/csv' ? toPayload(parseCsv(text), table) : toPayload(JSON.parse(text), table);
|
||||
} catch (e) {
|
||||
return json(
|
||||
{ ok: false, error: e instanceof Error ? e.message : 'Bad input' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { clean, errors } = validatePayload(payload);
|
||||
if (errors.length) return json({ ok: false, errors: errors.slice(0, 100) }, { status: 400 });
|
||||
const rows = Object.fromEntries(Object.entries(clean).map(([t, r]) => [t, r.length]));
|
||||
if (event.url.searchParams.get('dry_run') === '1') return json({ ok: true, dryRun: true, rows });
|
||||
try {
|
||||
return json({ ok: true, counts: await writeRows(db, clean) });
|
||||
} catch (e) {
|
||||
return json(
|
||||
{ ok: false, error: e instanceof Error ? e.message : 'Import failed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Any other method: still 404 for non-owners (no 405 that would reveal the endpoint).
|
||||
export const fallback: RequestHandler = (event) => {
|
||||
requireOwner(event);
|
||||
error(405, 'Use POST');
|
||||
};
|
||||
17
tests/energy.test.ts
Normal file
17
tests/energy.test.ts
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user