2 Commits

64 changed files with 2134 additions and 17892 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ export default defineConfig({
schema: './src/lib/server/db/schema/index.ts', schema: './src/lib/server/db/schema/index.ts',
out: './drizzle', out: './drizzle',
schemaFilter: [ schemaFilter: [
'books',
'auth', 'auth',
'money', 'money',
'house', 'house',
@@ -19,8 +20,7 @@ export default defineConfig({
'cs', 'cs',
'animaldex', 'animaldex',
'site', 'site',
'meta', 'meta'
'health'
], ],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' }, migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true, verbose: true,

View File

@@ -0,0 +1,146 @@
CREATE SCHEMA "books";
--> statement-breakpoint
CREATE TYPE "books"."metadata_status" AS ENUM('pending', 'matched', 'low_confidence', 'not_found', 'manual');--> statement-breakpoint
CREATE TABLE "books"."files" (
"id" serial PRIMARY KEY,
"work_id" integer NOT NULL,
"path" text NOT NULL,
"format" text NOT NULL,
"size_bytes" bigint NOT NULL,
"sha256" text NOT NULL,
"md5" text,
"source" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "books"."notes" (
"id" serial PRIMARY KEY,
"work_id" integer NOT NULL,
"file_id" integer,
"locator" text,
"page" integer,
"highlight" text,
"note" text,
"color" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "books"."progress" (
"id" serial PRIMARY KEY,
"work_id" integer NOT NULL,
"file_id" integer,
"locator" text,
"page" integer,
"percent" real DEFAULT 0 NOT NULL,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "progress_percent_check" CHECK ("percent" between 0 and 100)
);
--> statement-breakpoint
CREATE TABLE "books"."shelf_works" (
"shelf_id" integer,
"work_id" integer,
"sort_order" integer DEFAULT 0 NOT NULL,
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "shelf_works_pkey" PRIMARY KEY("shelf_id","work_id")
);
--> statement-breakpoint
CREATE TABLE "books"."shelves" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL,
"name" text NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "books"."works" (
"id" serial PRIMARY KEY,
"title" text NOT NULL,
"subtitle" text,
"authors" text[] DEFAULT '{}'::text[] NOT NULL,
"isbn10" text,
"isbn13" text,
"publisher" text,
"year" integer,
"language" text,
"page_count" integer,
"description" text,
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
"cover_url" text,
"cover_path" text,
"openlibrary_work_id" text,
"openlibrary_edition_id" text,
"google_books_id" text,
"annas_md5" text[] DEFAULT '{}'::text[] NOT NULL,
"source_refs" jsonb DEFAULT '{}' NOT NULL,
"metadata_status" "books"."metadata_status" DEFAULT 'pending'::"books"."metadata_status" NOT NULL,
"metadata_source" text,
"metadata_fetched_at" timestamp with time zone,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "works_year_check" CHECK ("year" is null or "year" between 0 and 2100)
);
--> statement-breakpoint
CREATE UNIQUE INDEX "files_path_key" ON "books"."files" ("path");--> statement-breakpoint
CREATE UNIQUE INDEX "files_sha256_key" ON "books"."files" ("sha256");--> statement-breakpoint
CREATE INDEX "files_work_id_idx" ON "books"."files" ("work_id");--> statement-breakpoint
CREATE INDEX "notes_work_id_idx" ON "books"."notes" ("work_id");--> statement-breakpoint
CREATE UNIQUE INDEX "progress_work_id_key" ON "books"."progress" ("work_id");--> statement-breakpoint
CREATE INDEX "shelf_works_work_id_idx" ON "books"."shelf_works" ("work_id");--> statement-breakpoint
CREATE UNIQUE INDEX "shelves_slug_key" ON "books"."shelves" ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "works_isbn13_key" ON "books"."works" ("isbn13") WHERE "isbn13" is not null;--> statement-breakpoint
CREATE INDEX "works_title_idx" ON "books"."works" (lower("title"));--> statement-breakpoint
ALTER TABLE "books"."files" ADD CONSTRAINT "files_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "books"."notes" ADD CONSTRAINT "notes_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "books"."notes" ADD CONSTRAINT "notes_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "books"."progress" ADD CONSTRAINT "progress_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "books"."progress" ADD CONSTRAINT "progress_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "books"."shelf_works" ADD CONSTRAINT "shelf_works_shelf_id_shelves_id_fkey" FOREIGN KEY ("shelf_id") REFERENCES "books"."shelves"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "books"."shelf_works" ADD CONSTRAINT "shelf_works_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;
--> statement-breakpoint
-- Hand-written: updated_at triggers, grants, default shelves, GUI registration.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['works','files','shelves','progress','notes'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON books.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA books TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA books TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA books TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT USAGE ON SCHEMA books TO personal_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA books TO personal_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA books GRANT SELECT ON TABLES TO personal_ro;
END IF;
END $$;
--> statement-breakpoint
INSERT INTO books.shelves (slug, name, sort_order) VALUES
('to-read', 'To read', 10), ('reading', 'Reading', 20), ('done', 'Done', 30), ('reference', 'Reference', 40)
ON CONFLICT DO NOTHING;
--> statement-breakpoint
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('books','works','books-works','Books','core',true,false,1000),
('books','shelves','books-shelves','Book shelves','core',true,false,1000),
('books','files','books-files','Book files','core',false,false,1000)
ON CONFLICT DO NOTHING;
PERFORM meta.sync();
END IF;
END $$;

View File

@@ -1,148 +0,0 @@
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 $$;

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,112 +0,0 @@
// 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();
}

View File

@@ -1,48 +0,0 @@
// 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();
}

945
scripts/books-ingest.py Normal file
View File

@@ -0,0 +1,945 @@
#!/usr/bin/env python3
"""Books ingest + metadata enrichment.
Builds books.works / books.files from:
* lists.books rows (the old GUI list) -> placeholders
* Obsidian "Book links.md" md5s resolved via the libgen JSON API -> placeholders
* ebook files dropped in $BOOKS_ROOT/inbox (sha256-deduped) -> works + files
Metadata: embedded (EPUB OPF / PDF info + ISBN scan) -> Open Library -> Google Books
(-> arXiv for anything tagged paper). Covers go to $BOOKS_ROOT/covers/<work_id>.jpg.
Files are moved to $BOOKS_ROOT/library/<Author>/<Title>/.
Usage:
DB_URL=postgresql://... BOOKS_ROOT=~/books \
python scripts/books-ingest.py --obsidian-json resolved.json [--reset] [--dry-run]
Network calls are cached in $BOOKS_ROOT/.cache/http so reruns are cheap and polite.
Refuses to run against the prod database name ('personal') unless --allow-prod.
"""
from __future__ import annotations
import argparse, difflib, hashlib, html, json, os, re, shutil, sys, time, unicodedata, urllib.parse, urllib.request, zipfile
from dataclasses import dataclass, field
from pathlib import Path
from xml.etree import ElementTree as ET
UA = "stan-books-ingest/0.1 (personal library catalogue; low volume)"
LANG = {"eng": "en", "dut": "nl", "nld": "nl", "ger": "de", "deu": "de", "fre": "fr", "fra": "fr", "spa": "es", "ita": "it",
"por": "pt", "rus": "ru", "jpn": "ja", "kor": "ko", "chi": "zh", "zho": "zh", "lat": "la", "gre": "el", "swe": "sv"}
BOOK_EXT = {".pdf", ".epub", ".mobi", ".azw3", ".azw", ".lit", ".djvu", ".docx", ".fb2", ".cbz"}
# ---------------------------------------------------------------- helpers
def strip_accents(s: str) -> str:
s = (s or "").replace("'", "").replace("’", "")
return "".join(c for c in unicodedata.normalize("NFKD", s) if not unicodedata.combining(c))
def norm_title(t: str) -> str:
t = strip_accents(html.unescape(t or "")).lower().replace("&", " and ")
t = re.split(r"\s*[:(\[]\s*|\s+[-–—]\s+|_\s", t, maxsplit=1)[0] # main title only
t = re.sub(r"[^a-z0-9]+", " ", t).strip()
t = re.sub(r"^(the|a|an|de|het|een)\s+", "", t)
t = re.sub(r"\s+(\d+(st|nd|rd|th)?\s+edition|ed)$", "", t)
return t
def full_norm(t: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", strip_accents(t or "").lower()).strip()
def sim(a: str, b: str) -> float:
na, nb = norm_title(a), norm_title(b)
a, b = full_norm(a), full_norm(b)
if not a or not b:
return 0.0
if a == b:
return 1.0
# volume / part / edition markers must agree ("Finance I" vs "Finance II", "Part 1" vs "Part 2")
marks = lambda x: {t for t in x.split() if re.fullmatch(r"\d{1,2}|[ivx]{1,4}", t)}
if marks(a) != marks(b) and marks(a) and marks(b):
return 0.5
r = difflib.SequenceMatcher(None, a, b).ratio()
if na and na == nb and len(na) >= 15:
r = max(r, 0.93)
return r
def surnames(authors: list[str]) -> set[str]:
out = set()
for a in authors or []:
a = strip_accents(a).lower()
if "," in a: # "King, Michael"
a = a.split(",")[0]
parts = re.findall(r"[a-z]{3,}", a)
if parts:
out.add(parts[-1] if "," not in a else parts[0])
out.update(parts)
return out
JUNK_AUTHORS = {"admin", "administrator", "user", "owner", "unknown", "oreily", "oreilly", "o'reilly", "author", "john", "mr2"}
def clean_authors(names) -> list[str]:
out = []
for n in names or []:
n = re.sub(r"\s+", " ", (n or "").strip(" ,;"))
low = n.lower()
if not n or low in JUNK_AUTHORS or re.search(r"\d|@|\.(com|org|net)\b", n) or " " not in n and "," not in n and "." not in n:
continue
if n.isupper():
n = n.title()
out.append(n)
return out
def authors_compatible(a: list[str], b: list[str]) -> bool:
if not a or not b:
return True
return bool(surnames(a) & surnames(b))
def isbn_ok(s: str) -> str | None:
s = re.sub(r"[^0-9Xx]", "", s or "").upper()
if len(s) == 13 and s.isdigit() and s[:3] in ("978", "979"):
tot = sum(int(c) * (1 if i % 2 == 0 else 3) for i, c in enumerate(s[:12]))
return s if (10 - tot % 10) % 10 == int(s[12]) else None
if len(s) == 10 and s[:9].isdigit():
tot = sum((10 - i) * int(c) for i, c in enumerate(s[:9]))
chk = (11 - tot % 11) % 11
return s if (("X" if chk == 10 else str(chk)) == s[9]) else None
return None
def to13(s: str) -> str | None:
s = isbn_ok(s)
if not s:
return None
if len(s) == 13:
return s
core = "978" + s[:9]
tot = sum(int(c) * (1 if i % 2 == 0 else 3) for i, c in enumerate(core))
return core + str((10 - tot % 10) % 10)
def to10(s13: str) -> str | None:
if not s13 or not s13.startswith("978"):
return None
core = s13[3:12]
tot = sum((10 - i) * int(c) for i, c in enumerate(core))
chk = (11 - tot % 11) % 11
return core + ("X" if chk == 10 else str(chk))
ISBN_RE = re.compile(r"ISBN(?:-1[03])?[:\s]*((?:97[89][\s-]?)?(?:\d[\s-]?){9}[\dXx])", re.I)
def find_isbns(text: str) -> list[str]:
out = []
for m in ISBN_RE.finditer(text or ""):
i = to13(m.group(1))
if i and i not in out:
out.append(i)
return out
def safe_name(s: str, n: int = 80) -> str:
s = strip_accents(s or "").replace("/", "-").replace("\\", "-")
s = re.sub(r'[<>:"|?*\x00-\x1f]', "", s)
s = re.sub(r"\s+", " ", s).strip(" .")
return (s[:n].rstrip(" .") or "Untitled")
def year_of(v) -> int | None:
m = re.search(r"(1[5-9]\d\d|20\d\d)", str(v or ""))
return int(m.group(1)) if m else None
# ---------------------------------------------------------------- http with cache
class Http:
def __init__(self, cache: Path, delay: float = 1.0):
self.cache, self.delay, self.last = cache, delay, 0.0
cache.mkdir(parents=True, exist_ok=True)
self.calls = 0
self.dead_hosts: set[str] = set()
def _get(self, url: str, binary=False):
host = urllib.parse.urlparse(url).netloc
if host in self.dead_hosts: # daily quota hit (Google Books): stop hammering it this run
return 429, b""
wait = self.delay - (time.time() - self.last)
if wait > 0:
time.sleep(wait)
self.last = time.time()
self.calls += 1
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "*/*"})
for attempt in range(3):
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read()
except urllib.error.HTTPError as e:
if e.code == 429 and "googleapis" in host:
self.dead_hosts.add(host)
log(f" {host}: quota exhausted (429), skipping it for the rest of this run")
return 429, b""
if e.code in (429, 503) and attempt < 2:
time.sleep(5 * (attempt + 1))
continue
return e.code, b""
except Exception:
if attempt < 2:
time.sleep(3)
continue
return 0, b""
return 0, b""
def json(self, url: str):
key = self.cache / (hashlib.sha1(url.encode()).hexdigest() + ".json")
if key.exists():
return json.loads(key.read_text())
status, body = self._get(url)
data = None
if status == 200:
try:
data = json.loads(body)
except Exception:
data = None
if status in (200, 404): # don't cache transient errors
key.write_text(json.dumps(data))
return data
def text(self, url: str):
key = self.cache / (hashlib.sha1(url.encode()).hexdigest() + ".txt")
if key.exists():
return key.read_text()
status, body = self._get(url)
if status == 200:
key.write_text(body.decode("utf-8", "replace"))
return body.decode("utf-8", "replace")
return None
def download(self, url: str, dest: Path) -> bool:
status, body = self._get(url, binary=True)
if status == 200 and len(body) > 1500: # skip 1x1 placeholders
dest.write_bytes(body)
return True
return False
# ---------------------------------------------------------------- model
@dataclass
class Work:
title: str
authors: list[str] = field(default_factory=list)
subtitle: str | None = None
isbns: list[str] = field(default_factory=list) # isbn13s, best first
publisher: str | None = None
year: int | None = None
language: str | None = None
page_count: int | None = None
description: str | None = None
tags: list[str] = field(default_factory=list)
cover_url: str | None = None
cover_path: str | None = None
ol_work: str | None = None
ol_edition: str | None = None
gb_id: str | None = None
md5s: list[str] = field(default_factory=list)
refs: dict = field(default_factory=dict)
status: str = "pending"
source: str | None = None
notes: str | None = None
files: list = field(default_factory=list)
shelves: set = field(default_factory=set)
origins: set = field(default_factory=set)
db_id: int | None = None
def add_ref(self, k, v):
self.refs.setdefault(k, [])
if v not in self.refs[k]:
self.refs[k].append(v)
@dataclass
class FileRec:
path: Path
rel_inbox: str
sha256: str
md5: str
size: int
fmt: str
title: str | None = None
authors: list[str] = field(default_factory=list)
isbns: list[str] = field(default_factory=list)
year: int | None = None
publisher: str | None = None
language: str | None = None
pages: int | None = None
is_paper: bool = False
broken: str | None = None
dest_rel: str | None = None
# ---------------------------------------------------------------- embedded metadata
def epub_meta(p: Path) -> dict:
out: dict = {}
try:
with zipfile.ZipFile(p) as z:
cont = ET.fromstring(z.read("META-INF/container.xml"))
opf_path = next(e.get("full-path") for e in cont.iter() if e.tag.endswith("rootfile"))
opf = ET.fromstring(z.read(opf_path))
md = next((e for e in opf.iter() if e.tag.endswith("metadata")), None)
if md is not None:
for e in md:
tag = e.tag.split("}")[-1]
txt = (e.text or "").strip()
if not txt:
continue
if tag == "title" and "title" not in out:
out["title"] = txt
elif tag == "creator":
out.setdefault("authors", []).append(txt)
elif tag == "identifier":
i = to13(txt.replace("urn:isbn:", ""))
if i:
out.setdefault("isbns", []).append(i)
elif tag == "publisher":
out.setdefault("publisher", txt)
elif tag == "date":
out.setdefault("year", year_of(txt))
elif tag == "language":
out.setdefault("language", txt[:2].lower())
# scan the first few content docs (copyright page) for ISBNs
names = [n for n in z.namelist() if n.lower().endswith((".xhtml", ".html", ".htm"))][:12]
for n in names:
for i in find_isbns(re.sub(r"<[^>]+>", " ", z.read(n).decode("utf-8", "ignore"))):
out.setdefault("isbns", [])
if i not in out["isbns"]:
out["isbns"].append(i)
except Exception as e: # noqa
out["error"] = f"epub: {e.__class__.__name__}"
return out
def pdf_meta(p: Path) -> dict:
out: dict = {}
try:
from pypdf import PdfReader
import logging
logging.getLogger("pypdf").setLevel(logging.ERROR)
r = PdfReader(str(p), strict=False)
out["pages"] = len(r.pages)
info = r.metadata or {}
t = (info.get("/Title") or "").strip() if info else ""
a = (info.get("/Author") or "").strip() if info else ""
if t:
out["title"] = t
if a:
out["authors"] = clean_authors(re.split(r";|\band\b|&", a))
text = ""
for i in range(min(len(r.pages), 10)):
try:
text += (r.pages[i].extract_text() or "") + "\n"
except Exception:
pass
out["isbns"] = find_isbns(text)
if re.search(r"arXiv:\d{4}\.\d{4,5}", text):
out["arxiv"] = re.search(r"arXiv:(\d{4}\.\d{4,5})", text).group(1)
except Exception as e: # noqa
out["error"] = f"pdf: {e.__class__.__name__}"
return out
ANNAS_RE = re.compile(r"^(?P<title>.+?) -- (?P<author>.+?) -- (?P<rest>.+?) -- (?P<md5>[0-9a-f]{32}) -- Anna’s Archive$")
def filename_meta(stem: str) -> dict:
out: dict = {}
m = ANNAS_RE.match(stem)
if m:
out["title"] = m["title"].replace("_ ", ": ").replace("_", " ")
out["authors"] = [a.strip().replace("_", ".") for a in m["author"].split(",") if a.strip()]
out["md5"] = m["md5"]
for part in m["rest"].split(" -- "):
i = to13(part)
if i:
out.setdefault("isbns", []).append(i)
elif year_of(part) and "year" not in out:
out["year"] = year_of(part)
return out
s = re.sub(r"\s*-\s*libgen\.(li|rs|is)$", "", stem)
s = re.sub(r"\s*\((z-library|z-lib\.org|1lib\.\w+)\)", "", s, flags=re.I)
s = re.sub(r"\s+(penguin (modern )?classics)$", "", s, flags=re.I)
s = re.sub(r"\s+0$", "", s)
mm = re.match(r"^([^-]+?) : ([^-]+?) - (.+)$", s) # "A : B - Title" (libgen multi-author)
if mm:
out["authors"] = [mm[1].strip(), mm[2].strip()]
s = mm[3]
pm = re.search(r"\s*\((\d{4}),\s*([^)]+)\)\s*$", s)
if pm:
out["year"], out["publisher"] = int(pm[1]), pm[2].strip()
s = s[: pm.start()]
# "Author - Title" (only when the left part looks like a person name)
mm = re.match(r"^([A-Z][a-z]+(?: [A-Z][a-z]+){1,2}) - (.+)$", s)
if mm:
out["authors"] = [mm[1]]
s = mm[2]
mm = re.match(r"^(.+?) by ([A-Z][\w.]+(?: [A-Z][\w.]+){1,2})$", s)
if mm:
s, out["authors"] = mm[1], [mm[2]]
if s.count("-") >= 4 and " " not in s: # The-Lean-Startup_-How-...-Eric-Ries-2011-Crown-Business-030788791X-
for tok in s.split("-"):
i = to13(tok)
if i:
out.setdefault("isbns", []).append(i)
s = s.split("_")[0].replace("-", " ")
s = s.replace("_ ", ": ").replace("_", " ")
if s.count(".") >= 3 and s.count(" ") <= 1: # Sipser_Introduction.to.the.Theory.of.Computation.3E
s = s.replace(".", " ")
s = re.sub(r"\s+", " ", s).strip(" -")
out["title"] = s
return out
def good_embedded_title(t: str | None, fname_title: str) -> bool:
if not t or len(t) < 4:
return False
bad = ("untitled", "microsoft word", ".doc", ".pdf", ".indd", "layout 1", "unknown", "title", "cover")
tl = t.lower()
if any(b in tl for b in bad) or re.fullmatch(r"[\w-]+\.\w+", t) or re.fullmatch(r"[0-9a-f\-_ ]+", tl):
return False
return sim(t, fname_title) > 0.55
# ---------------------------------------------------------------- enrichment
class Enricher:
def __init__(self, http: Http):
self.h = http
def ol_isbn(self, isbn: str):
ed = self.h.json(f"https://openlibrary.org/isbn/{isbn}.json")
if not ed or "title" not in ed:
return None
rec = {"title": ed.get("title"), "subtitle": ed.get("subtitle"), "publisher": (ed.get("publishers") or [None])[0],
"year": year_of(ed.get("publish_date")), "pages": ed.get("number_of_pages"),
"ol_edition": ed.get("key", "").split("/")[-1] or None, "isbn": isbn,
"cover": f"https://covers.openlibrary.org/b/id/{ed['covers'][0]}-L.jpg" if ed.get("covers") and ed["covers"][0] > 0 else None,
"lang": LANG.get(((ed.get("languages") or [{}])[0].get("key") or "").split("/")[-1])}
wk = (ed.get("works") or [{}])[0].get("key")
if wk:
w = self.h.json(f"https://openlibrary.org{wk}.json") or {}
rec["ol_work"] = wk.split("/")[-1]
d = w.get("description")
rec["description"] = d.get("value") if isinstance(d, dict) else d
rec["subjects"] = (w.get("subjects") or [])[:8]
names = []
for a in (w.get("authors") or [])[:4]:
k = (a.get("author") or {}).get("key")
if k:
aj = self.h.json(f"https://openlibrary.org{k}.json") or {}
if aj.get("name"):
names.append(aj["name"])
rec["authors"] = names
if not rec["cover"] and w.get("covers") and w["covers"][0] > 0:
rec["cover"] = f"https://covers.openlibrary.org/b/id/{w['covers'][0]}-L.jpg"
return rec
def ol_search(self, title: str, authors: list[str]):
q = {"title": norm_title(title) if len(norm_title(title)) > 3 else title, "limit": 8,
"fields": "key,title,subtitle,author_name,first_publish_year,isbn,cover_i,cover_edition_key,language,number_of_pages_median,publisher,subject"}
if authors:
q["author"] = re.sub(r"[^\w\s.-]", " ", authors[0])
d = self.h.json("https://openlibrary.org/search.json?" + urllib.parse.urlencode(q))
docs = (d or {}).get("docs") or []
if not docs and authors:
q.pop("author")
d = self.h.json("https://openlibrary.org/search.json?" + urllib.parse.urlencode(q))
docs = (d or {}).get("docs") or []
best, bs = None, 0.0
for doc in docs:
t = doc.get("title", "")
s = max(sim(title, t), sim(title, f"{t}: {doc.get('subtitle', '')}"))
if norm_title(title) and norm_title(title) == norm_title(t) and (
authors and doc.get("author_name") and authors_compatible(authors, doc["author_name"]) or len(norm_title(t)) >= 15):
s = max(s, 0.92)
if authors and doc.get("author_name"):
s += 0.05 if authors_compatible(authors, doc["author_name"]) else -0.25
if s > bs:
best, bs = doc, s
if not best:
return None, 0.0
isbns = [to13(i) for i in (best.get("isbn") or [])]
isbns = [i for i in isbns if i]
rec = {"title": best.get("title"), "subtitle": best.get("subtitle"), "authors": best.get("author_name") or [],
"year": best.get("first_publish_year"), "ol_work": best.get("key", "").split("/")[-1] or None,
"ol_edition": best.get("cover_edition_key"), "pages": best.get("number_of_pages_median"),
"publisher": (best.get("publisher") or [None])[0],
"cover": f"https://covers.openlibrary.org/b/id/{best['cover_i']}-L.jpg" if best.get("cover_i") else None,
"lang": LANG.get((best.get("language") or [""])[0]), "subjects": (best.get("subject") or [])[:8],
"isbn": next((i for i in isbns if i.startswith("978")), isbns[0] if isbns else None)}
if rec["ol_work"]:
w = self.h.json(f"https://openlibrary.org/works/{rec['ol_work']}.json") or {}
dsc = w.get("description")
rec["description"] = dsc.get("value") if isinstance(dsc, dict) else dsc
return rec, min(bs, 1.0)
def gb(self, title: str | None, authors: list[str], isbn: str | None = None):
if isbn:
q = f"isbn:{isbn}"
else:
q = f'intitle:"{norm_title(title) or title}"' + (f" inauthor:{surnames(authors[:1]).pop()}" if authors and surnames(authors[:1]) else "")
d = self.h.json("https://www.googleapis.com/books/v1/volumes?" + urllib.parse.urlencode({"q": q, "maxResults": 8, "printType": "books"}))
items = (d or {}).get("items") or []
best, bs = None, 0.0
for it in items:
v = it.get("volumeInfo", {})
if isbn:
s = 1.0
else:
s = max(sim(title, v.get("title", "")), sim(title, f"{v.get('title', '')}: {v.get('subtitle', '')}"))
if norm_title(title) and norm_title(title) == norm_title(v.get("title", "")) and (
authors and v.get("authors") and authors_compatible(authors, v["authors"]) or len(norm_title(title)) >= 15):
s = max(s, 0.92)
if authors and v.get("authors"):
s += 0.05 if authors_compatible(authors, v["authors"]) else -0.25
if s > bs:
best, bs = it, s
if isbn:
break
if not best:
return None, 0.0
v = best["volumeInfo"]
ids = {x.get("type"): x.get("identifier") for x in v.get("industryIdentifiers", [])}
img = (v.get("imageLinks") or {})
cover = img.get("extraLarge") or img.get("large") or img.get("medium") or img.get("thumbnail")
if cover:
cover = cover.replace("http://", "https://").replace("&edge=curl", "")
return {"title": v.get("title"), "subtitle": v.get("subtitle"), "authors": v.get("authors") or [],
"publisher": v.get("publisher"), "year": year_of(v.get("publishedDate")), "pages": v.get("pageCount"),
"description": re.sub(r"<[^>]+>", "", v.get("description") or "") or None,
"lang": (v.get("language") or "")[:2] or None, "gb_id": best.get("id"), "cover": cover,
"isbn": to13(ids.get("ISBN_13") or ids.get("ISBN_10") or ""), "subjects": v.get("categories") or []}, min(bs, 1.0)
def arxiv(self, title: str, arxiv_id: str | None = None):
if arxiv_id:
url = f"https://export.arxiv.org/api/query?id_list={arxiv_id}"
else:
t = re.sub(r"[^\w\s]", " ", title)
url = "https://export.arxiv.org/api/query?" + urllib.parse.urlencode({"search_query": f'ti:"{t}"', "max_results": 5})
x = self.h.text(url)
if not x:
return None, 0.0
ns = {"a": "http://www.w3.org/2005/Atom"}
try:
root = ET.fromstring(x)
except ET.ParseError:
return None, 0.0
best, bs = None, 0.0
for e in root.findall("a:entry", ns):
t = re.sub(r"\s+", " ", e.findtext("a:title", "", ns)).strip()
if not t or t == "Error":
continue
s = 1.0 if arxiv_id else sim(title, t)
if s > bs:
best, bs = (e, t), s
if not best:
return None, 0.0
e, t = best
return {"title": t, "authors": [a.findtext("a:name", "", ns) for a in e.findall("a:author", ns)],
"year": year_of(e.findtext("a:published", "", ns)),
"description": re.sub(r"\s+", " ", e.findtext("a:summary", "", ns)).strip(),
"arxiv": e.findtext("a:id", "", ns).split("/abs/")[-1]}, bs
# ---------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--obsidian-json", help="md5 -> libgen record map from the resolver")
ap.add_argument("--extra", action="append", default=[], help='extra placeholder "Title|Author|isbn13|origin"')
ap.add_argument("--reset", action="store_true", help="truncate books.works/files first (scratch DBs only)")
ap.add_argument("--dry-run", action="store_true", help="no DB writes, no file moves; write plan json")
ap.add_argument("--no-network", action="store_true")
ap.add_argument("--allow-prod", action="store_true")
ap.add_argument("--source", default="gdrive")
args = ap.parse_args()
root = Path(os.path.expanduser(os.environ.get("BOOKS_ROOT", "/srv/books")))
inbox, library, covers = root / "inbox", root / "library", root / "covers"
for d in (inbox, library, covers):
d.mkdir(parents=True, exist_ok=True)
db_url = os.environ.get("DB_URL")
if not db_url:
sys.exit("DB_URL required")
dbname = urllib.parse.urlparse(db_url).path.lstrip("/")
if dbname == "personal" and not args.allow_prod:
sys.exit("refusing to run against prod DB 'personal' (use --allow-prod)")
import psycopg
conn = psycopg.connect(db_url)
http = Http(root / ".cache" / "http", delay=0.0 if args.no_network else 1.0)
en = Enricher(http)
works: list[Work] = []
log = lambda *a: print(*a, flush=True) # noqa
# ---- 1. placeholders: lists.books
with conn.cursor() as cur:
cur.execute("select to_regclass('lists.books')")
has_lists = cur.fetchone()[0] is not None
rows = []
if has_lists:
cur.execute("select id, name, author, notes, done_at from lists.books order by sort_order nulls last, id")
rows = cur.fetchall()
for bid, name, author, notes, done_at in rows:
w = Work(title=name.strip(), authors=[author.strip()] if author else [], notes=notes or None)
w.add_ref("lists_books_id", bid)
w.origins.add("lists")
w.shelves.add("done" if done_at else "to-read")
if done_at:
w.refs["done_at"] = done_at.isoformat()
works.append(w)
n_lists = len(rows)
def find_work(title, authors, isbns, md5=None):
for w in works:
if md5 and md5 in w.md5s:
return w
if isbns and set(isbns) & set(w.isbns) and (not title or not w.title or sim(title, w.title) >= 0.85):
return w
best, bs = None, 0.0
for w in works:
s = sim(title, w.title)
if s >= 0.9 and authors_compatible(authors, w.authors) and s > bs:
best, bs = w, s
return best
def merge(w: Work, title, authors, isbns, **kw):
if authors and not w.authors:
w.authors = authors
for i in isbns or []:
if i not in w.isbns:
w.isbns.append(i)
for k in ("year", "publisher", "language"):
if kw.get(k) and not getattr(w, k):
setattr(w, k, kw[k])
# prefer the longer, properly-cased title when the list name is a lowercase shorthand
if title and (w.title.islower() or len(norm_title(w.title)) < len(norm_title(title)) and sim(w.title, title) > 0.9 and w.title == w.title.lower()):
w.title = title
# ---- 2. placeholders: Obsidian md5s
obs_total = obs_resolved = obs_overlap = 0
if args.obsidian_json:
data = json.load(open(args.obsidian_json))
for md5, rec in data.items():
obs_total += 1
eds = (rec or {}).get("editions") or []
if not eds:
continue
obs_resolved += 1
ed = next((e for e in eds if e.get("isbns")), eds[0])
isbns = []
for e in eds:
for i in e.get("isbns") or []:
i13 = to13(i)
if i13 and i13 not in isbns:
isbns.append(i13)
title = (ed.get("title") or rec.get("locator") or md5).strip()
authors = [a.strip() for a in re.split(r";|, (?=[A-Z][a-z]+ [A-Z])", ed.get("author") or "") if a.strip()]
lang = LANG.get((ed.get("lang") or [""])[0], None) if isinstance(ed.get("lang"), list) else None
w = find_work(title, authors, isbns, md5)
if w:
if "obsidian" not in w.origins:
obs_overlap += 1
merge(w, title, authors, isbns, year=year_of(ed.get("year")), publisher=ed.get("publisher") or None, language=lang)
else:
w = Work(title=title, authors=authors, isbns=isbns, year=year_of(ed.get("year")), publisher=ed.get("publisher") or None, language=lang)
works.append(w)
w.shelves.add("to-read")
w.origins.add("obsidian")
if md5 not in w.md5s:
w.md5s.append(md5)
w.add_ref("obsidian", {"md5": md5, "ext": rec.get("ext"), "filesize": rec.get("filesize")})
for ex in args.extra:
t, a, i, origin = (ex.split("|") + ["", "", "", ""])[:4]
isbns = [to13(i)] if to13(i) else []
authors = [a] if a else []
w = find_work(t, authors, isbns)
if w:
merge(w, t, authors, isbns)
else:
w = Work(title=t, authors=authors, isbns=isbns)
works.append(w)
w.shelves.add("to-read")
w.origins.add("obsidian")
w.add_ref("obsidian_link", origin)
n_placeholders = len(works)
# ---- 3. files in inbox
files: list[FileRec] = []
for p in sorted(inbox.rglob("*")):
if not p.is_file() or p.name.startswith("."):
continue
h256, h5 = hashlib.sha256(), hashlib.md5()
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h256.update(chunk)
h5.update(chunk)
rel = str(p.relative_to(inbox))
ext = p.suffix.lower()
with open(p, "rb") as hf:
head = hf.read(8)
fmt = ext.lstrip(".") or "bin"
if head.startswith(b"%PDF"):
fmt = "pdf"
elif head.startswith(b"PK") and ext in (".epub", ""):
fmt = "epub"
fr = FileRec(path=p, rel_inbox=rel, sha256=h256.hexdigest(), md5=h5.hexdigest(), size=p.stat().st_size, fmt=fmt)
fr.is_paper = rel.split("/")[0].lower() == "papers"
if ext == ".crdownload":
fr.broken = "partial Chrome download (.crdownload)"
fr.fmt = "pdf" if head.startswith(b"%PDF") else "partial"
files.append(fr)
by_sha: dict[str, FileRec] = {}
dups: list[tuple[FileRec, FileRec]] = []
uniq: list[FileRec] = []
for fr in files:
if fr.sha256 in by_sha:
dups.append((fr, by_sha[fr.sha256]))
else:
by_sha[fr.sha256] = fr
uniq.append(fr)
log(f"files: {len(files)} total, {len(uniq)} unique, {len(dups)} sha256 duplicates")
# already-ingested shas (rerun without --reset)
with conn.cursor() as cur:
cur.execute("select sha256 from books.files")
known = {r[0] for r in cur.fetchall()} if not args.reset else set()
matched_existing = 0
for fr in uniq:
if fr.sha256 in known:
continue
stem = Path(fr.rel_inbox).name
stem = stem[: -len(Path(stem).suffix)] if Path(stem).suffix.lower() in BOOK_EXT | {".crdownload"} else stem
fm = filename_meta(stem)
em = epub_meta(fr.path) if fr.fmt == "epub" else pdf_meta(fr.path) if fr.fmt == "pdf" and not fr.broken else {}
fr.title = fm.get("title") or stem
if fr.fmt == "epub" and good_embedded_title(em.get("title"), fr.title):
fr.title = em["title"]
fr.authors = fm.get("authors") or [a for a in em.get("authors", []) if len(a) > 2 and "unknown" not in a.lower()][:4]
fr.isbns = list(dict.fromkeys((fm.get("isbns") or []) + (em.get("isbns") or [])))[:6]
fr.year = fm.get("year") or em.get("year")
fr.publisher = fm.get("publisher") or em.get("publisher")
fr.language = em.get("language")
fr.pages = em.get("pages")
md5 = fm.get("md5") or fr.md5
w = find_work(fr.title, fr.authors, fr.isbns[:2], md5) or (find_work(fr.title, fr.authors, [], fr.md5))
if w and w.files == [] and w.origins & {"lists", "obsidian"} and "drive" not in w.origins:
matched_existing += 1
if w:
merge(w, fr.title, fr.authors, fr.isbns[:2], year=fr.year, publisher=fr.publisher, language=fr.language)
else:
w = Work(title=fr.title, authors=fr.authors, isbns=fr.isbns[:2], year=fr.year, publisher=fr.publisher, language=fr.language, page_count=fr.pages)
works.append(w)
if fr.is_paper and "paper" not in w.tags:
w.tags.append("paper")
if fr.md5 not in w.md5s and fm.get("md5"):
w.md5s.append(fm["md5"])
w.origins.add("drive")
w.add_ref("drive", fr.rel_inbox)
if em.get("arxiv"):
w.refs["arxiv"] = em["arxiv"]
if fr.broken:
w.notes = ((w.notes + "\n") if w.notes else "") + f"Drive copy is broken: {fr.broken}; needs a fresh copy."
w.add_ref("broken_files", fr.rel_inbox)
else:
w.files.append(fr)
for d, orig in dups:
for w in works:
if orig in w.files:
w.add_ref("drive_duplicates", d.rel_inbox)
log(f"works: {len(works)} ({n_placeholders} placeholders before files; {matched_existing} placeholders got files)")
# ---- 4. enrichment
stats = {"matched": 0, "low_confidence": 0, "not_found": 0}
for n, w in enumerate(works, 1):
if args.no_network:
break
rec, score, src = None, 0.0, None
if "paper" in w.tags:
rec, score = en.arxiv(w.title, w.refs.get("arxiv"))
src = "arxiv"
else:
for isbn in w.isbns[:3]:
rec = en.ol_isbn(isbn)
if rec and (sim(w.title, rec["title"]) > 0.6 or sim(w.title, f"{rec['title']}: {rec.get('subtitle') or ''}") > 0.6 or "drive" not in w.origins):
score, src = 1.0, "openlibrary"
break
rec = None
if not rec:
rec, score = en.ol_search(w.title, w.authors)
src = "openlibrary"
if score < 0.9:
g, gs = en.gb(None, [], w.isbns[0]) if w.isbns else (None, 0.0)
if g and sim(w.title, g["title"] or "") < 0.6 and sim(w.title, f"{g['title']}: {g.get('subtitle') or ''}") < 0.6:
g, gs = None, 0.0
if not g:
g, gs = en.gb(w.title, w.authors)
if g and gs > score:
rec, score, src = g, gs, "googlebooks"
if rec and score >= 0.9:
w.status = "matched"
elif rec and score >= 0.75:
w.status = "low_confidence"
else:
w.status = "not_found"
stats[w.status] += 1
if rec:
w.refs["match"] = {"source": src, "score": round(score, 3), "title": rec.get("title"), "authors": rec.get("authors")}
if w.status == "matched":
w.source = src
if src == "openlibrary" or src == "googlebooks":
# keep Stan's/the file's title wording unless it was a lowercase list shorthand or a filename
# keep Stan's own list wording; otherwise take the catalogue's properly-cased title
if rec.get("title") and ("lists" not in w.origins or w.title.islower()):
w.title = rec["title"]
w.subtitle = w.subtitle or rec.get("subtitle")
elif src == "arxiv":
w.title = rec["title"]
if rec.get("authors"):
w.authors = rec["authors"][:6]
w.year = w.year or rec.get("year")
if src == "openlibrary" and rec.get("year") and w.year and rec["year"] < w.year:
w.year = w.year # edition year stays; first-publish year recorded in refs
w.refs["first_publish_year"] = rec["year"]
w.publisher = w.publisher or rec.get("publisher")
w.page_count = w.page_count or rec.get("pages")
w.language = w.language or rec.get("lang")
w.description = w.description or rec.get("description")
if rec.get("isbn") and rec["isbn"] not in w.isbns:
w.isbns.insert(0 if not w.isbns else len(w.isbns), rec["isbn"])
w.ol_work = w.ol_work or rec.get("ol_work")
w.ol_edition = w.ol_edition or rec.get("ol_edition")
w.gb_id = w.gb_id or rec.get("gb_id")
w.cover_url = w.cover_url or rec.get("cover")
if rec.get("arxiv"):
w.refs["arxiv"] = rec["arxiv"]
for s in (rec.get("subjects") or [])[:5]:
s = s.lower().strip()
if 2 < len(s) < 40 and s not in w.tags:
w.tags.append(s)
if n % 25 == 0:
log(f" enriched {n}/{len(works)} ({http.calls} http calls)")
if args.no_network:
stats = {"skipped": len(works)}
log("metadata:", stats)
# cover fallback for matched works lacking one: Google Books by isbn
if not args.no_network:
for w in works:
if not w.cover_url and w.status == "matched" and "paper" not in w.tags and w.isbns:
g, _ = en.gb(None, [], w.isbns[0])
if g and g.get("cover"):
w.cover_url = g["cover"]
w.gb_id = w.gb_id or g.get("gb_id")
# ---- 5. plan destinations
used: set[str] = set()
for w in works:
author_dir = safe_name(w.authors[0] if w.authors else "Unknown", 60)
title_dir = safe_name(w.title, 90)
for fr in w.files:
base = f"{author_dir}/{title_dir}/{title_dir}"
rel = f"{base}.{fr.fmt}"
k = 2
while rel in used or (library / rel).exists() and not args.reset:
rel = f"{base} ({k}).{fr.fmt}"
k += 1
used.add(rel)
fr.dest_rel = rel
plan = [{"title": w.title, "authors": w.authors, "isbns": w.isbns, "status": w.status, "origins": sorted(w.origins),
"files": [{"from": f.rel_inbox, "to": f.dest_rel, "sha256": f.sha256} for f in w.files], "refs": w.refs,
"cover": w.cover_url} for w in works]
(root / ".cache").mkdir(exist_ok=True)
(root / ".cache" / "ingest-plan.json").write_text(json.dumps(plan, indent=1, default=str))
if args.dry_run:
log("dry run: plan written to", root / ".cache" / "ingest-plan.json")
return
# ---- 6. write DB
def isbn13(w):
return next((i for i in w.isbns if i.startswith("978") or i.startswith("979")), None)
seen13 = set()
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
with conn.transaction():
cur = conn.cursor()
if args.reset:
cur.execute("truncate books.notes, books.progress, books.shelf_works, books.files, books.works restart identity cascade")
cur.execute("select slug, id from books.shelves")
shelves = dict(cur.fetchall())
for w in works:
i13 = isbn13(w)
if i13 in seen13:
w.refs["isbn13_shared_with_other_work"] = i13
i13 = None
if i13:
seen13.add(i13)
refs = dict(w.refs)
refs["origins"] = sorted(w.origins)
if len(w.isbns) > 1:
refs["all_isbns"] = w.isbns
cur.execute(
"""insert into books.works (title, subtitle, authors, isbn10, isbn13, publisher, year, language, page_count,
description, tags, cover_url, openlibrary_work_id, openlibrary_edition_id, google_books_id, annas_md5,
source_refs, metadata_status, metadata_source, metadata_fetched_at, notes)
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) returning id""",
(w.title, w.subtitle, w.authors, to10(i13) if i13 else None, i13, w.publisher,
w.year if w.year and 1400 < w.year < 2100 else None, w.language, w.page_count, w.description, w.tags,
w.cover_url, w.ol_work, w.ol_edition, w.gb_id, w.md5s, json.dumps(refs, default=str), w.status,
w.source, now if w.status != "pending" else None, w.notes))
w.db_id = cur.fetchone()[0]
for fr in w.files:
cur.execute("insert into books.files (work_id, path, format, size_bytes, sha256, md5, source) values (%s,%s,%s,%s,%s,%s,%s)",
(w.db_id, fr.dest_rel, fr.fmt, fr.size, fr.sha256, fr.md5, args.source))
for s in sorted(w.shelves):
if s in shelves:
cur.execute("insert into books.shelf_works (shelf_id, work_id) values (%s,%s) on conflict do nothing", (shelves[s], w.db_id))
if "done" in w.shelves and w.refs.get("done_at"):
cur.execute("insert into books.progress (work_id, percent, finished_at) values (%s, 100, %s) on conflict do nothing", (w.db_id, w.refs["done_at"]))
# ---- 7. covers
got = 0
if not args.no_network:
for w in works:
if not w.cover_url:
continue
dest = covers / f"{w.db_id}.jpg"
if dest.exists() or http.download(w.cover_url, dest):
got += 1
with conn.cursor() as cur:
cur.execute("update books.works set cover_path=%s where id=%s", (dest.name, w.db_id))
conn.commit()
# ---- 8. move files (inbox -> library); write undo manifest
manifest = root / ".cache" / f"moves-{time.strftime('%Y%m%d-%H%M%S')}.tsv"
dupdir = root / "duplicates"
qdir = root / "quarantine"
with open(manifest, "w") as mf:
for w in works:
for fr in w.files:
dest = library / fr.dest_rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(fr.path), dest)
mf.write(f"{fr.path}\t{dest}\n")
for d, _ in dups:
dest = dupdir / d.rel_inbox
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(d.path), dest)
mf.write(f"{d.path}\t{dest}\n")
for fr in uniq:
if fr.broken and fr.path.exists():
dest = qdir / fr.rel_inbox
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(fr.path), dest)
mf.write(f"{fr.path}\t{dest}\n")
for d in sorted(inbox.rglob("*"), reverse=True):
if d.is_dir() and not any(d.iterdir()):
d.rmdir()
summary = {
"lists_rows": n_lists, "obsidian_md5s": obs_total, "obsidian_resolved": obs_resolved,
"obsidian_overlapping_lists": obs_overlap, "placeholders": n_placeholders,
"files_total": len(files), "files_unique": len(uniq), "sha256_duplicates": len(dups),
"placeholders_with_files": matched_existing, "works_total": len(works),
"works_with_files": sum(1 for w in works if w.files), "metadata": stats, "covers": got,
"papers": sum(1 for w in works if "paper" in w.tags), "http_calls": http.calls, "manifest": str(manifest),
}
(root / ".cache" / "ingest-summary.json").write_text(json.dumps(summary, indent=1))
print(json.dumps(summary, indent=1))
if __name__ == "__main__":
main()

View File

@@ -107,39 +107,6 @@ try {
} }
if (schema.app_has_ddl) if (schema.app_has_ddl)
throw new Error('DB_URL must use the app role (personal_app), not the schema owner'); 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` const [catalog] = await client`
select select
(select count(*) from ref.pokemon) as pokemon, (select count(*) from ref.pokemon) as pokemon,

View File

@@ -1,72 +0,0 @@
/**
* 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();
}

View File

@@ -1,8 +1,6 @@
import type { Handle } from '@sveltejs/kit/hooks'; import type { Handle } from '@sveltejs/kit/hooks';
import { building } from '$app/env'; import { building } from '$app/env';
import { auth } from '#lib/server/auth.js'; 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 }) => { export const handle: Handle = async ({ event, resolve }) => {
// Match the endpoint path here: the dev proxy may terminate HTTPS before Vite. // Match the endpoint path here: the dev proxy may terminate HTTPS before Vite.
@@ -11,22 +9,5 @@ export const handle: Handle = async ({ event, resolve }) => {
const session = building ? null : await auth.api.getSession({ headers: event.request.headers }); const session = building ? null : await auth.api.getSession({ headers: event.request.headers });
event.locals.user = session?.user ?? null; event.locals.user = session?.user ?? null;
event.locals.session = session?.session ?? null; event.locals.session = session?.session ?? null;
// Owner-only areas (health tracker). Page loads, __data.json and the import endpoint return resolve(event);
// 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;
}; };

View File

@@ -12,7 +12,6 @@
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined'; import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
import FolderKanban from 'lucide-svelte/icons/folder-kanban'; import FolderKanban from 'lucide-svelte/icons/folder-kanban';
import Gamepad2 from 'lucide-svelte/icons/gamepad-2'; import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
import HeartPulse from 'lucide-svelte/icons/heart-pulse';
import House from 'lucide-svelte/icons/house'; import House from 'lucide-svelte/icons/house';
import Globe2 from 'lucide-svelte/icons/globe-2'; import Globe2 from 'lucide-svelte/icons/globe-2';
import Languages from 'lucide-svelte/icons/languages'; import Languages from 'lucide-svelte/icons/languages';
@@ -35,8 +34,7 @@
'money tracker': Wallet, 'money tracker': Wallet,
'cs prices': CandlestickChart, 'cs prices': CandlestickChart,
'house tracker': House, 'house tracker': House,
lists: ListChecks, lists: ListChecks
health: HeartPulse
}; };
const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban); const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban);

View File

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

View File

@@ -1,42 +0,0 @@
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));
});
});

View File

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

View File

@@ -1,118 +0,0 @@
<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>

View File

@@ -1,129 +0,0 @@
// 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);

View File

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

View File

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

View File

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

View File

@@ -58,12 +58,6 @@ export const trackers = [
href: '/projects/cs-prices', href: '/projects/cs-prices',
external: false external: false
}, },
{
name: 'energy',
subtitle: 'Dutch electricity mix, utilization and gas storage',
href: '/projects/energy',
external: false
},
{ {
name: 'animaldex', name: 'animaldex',
subtitle: 'add neighborhood pets to the world map', subtitle: 'add neighborhood pets to the world map',
@@ -77,19 +71,3 @@ export const trackers = [
external: false external: false
} }
] as const; ] 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;
}

View File

@@ -38,7 +38,7 @@ const publicPages: Record<string, { title: string; description: string }> = {
'/trackers': { '/trackers': {
title: 'Trackers', title: 'Trackers',
description: description:
'Stan Runge’s trackers: games, money, houses, Counter-Strike prices, Dutch energy, Animaldex and lists.' 'Stan Runge’s trackers: games, money, houses, Counter-Strike prices, Animaldex and lists.'
}, },
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' }, '/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
'/projects/game-tracker': { '/projects/game-tracker': {
@@ -69,11 +69,6 @@ const publicPages: Record<string, { title: string; description: string }> = {
description: description:
'Compare historical Counter-Strike market prices by listing, weapon, skin, wear, and other item dimensions.' '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': { '/projects/derby': {
title: 'Derby Lens', title: 'Derby Lens',
description: 'Estimate horse racing odds and compare them with market prices.' description: 'Estimate horse racing odds and compare them with market prices.'

View File

@@ -1,176 +0,0 @@
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');
});
});

View File

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

View File

@@ -0,0 +1,162 @@
import { sql } from 'drizzle-orm';
import {
bigint,
check,
index,
integer,
jsonb,
primaryKey,
real,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { booksSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// Book tracker + reader. A work is one book (any edition we care about); a work can
// exist without files (placeholder for a book Stan wants but doesn't have yet).
// Files live on stan-vm under $BOOKS_ROOT/library (target /srv/books/library); covers under $BOOKS_ROOT/covers.
export const bookMetadataStatus = booksSchema.enum('metadata_status', [
'pending', // not looked up yet
'matched', // confident match (ISBN or exact title+author)
'low_confidence', // fuzzy match, needs a human look
'not_found',
'manual' // edited by hand; enrichment never overwrites it
]);
export const bookWorks = booksSchema.table(
'works',
{
id: id(),
title: text('title').notNull(),
subtitle: text('subtitle'),
authors: text('authors').array().notNull().default([]),
isbn10: text('isbn10'),
isbn13: text('isbn13'),
publisher: text('publisher'),
year: integer('year'),
language: text('language'), // ISO 639-1 where known, e.g. 'en', 'nl'
pageCount: integer('page_count'),
description: text('description'),
tags: text('tags').array().notNull().default([]),
coverUrl: text('cover_url'), // remote source of the cover
coverPath: text('cover_path'), // local file, relative to /srv/books/covers
// External ids
openlibraryWorkId: text('openlibrary_work_id'), // e.g. OL45883W
openlibraryEditionId: text('openlibrary_edition_id'), // e.g. OL7353617M
googleBooksId: text('google_books_id'),
annasMd5: text('annas_md5').array().notNull().default([]), // Anna's Archive / libgen file md5s
sourceRefs: jsonb('source_refs').$type<Record<string, unknown>>().notNull().default({}),
metadataStatus: bookMetadataStatus('metadata_status').notNull().default('pending'),
metadataSource: text('metadata_source'), // 'openlibrary' | 'googlebooks' | 'libgen' | 'manual'
metadataFetchedAt: timestamptz('metadata_fetched_at'),
notes: text('notes'),
...timestamps
},
(t) => [
uniqueIndex('works_isbn13_key')
.on(t.isbn13)
.where(sql`${t.isbn13} is not null`),
index('works_title_idx').on(sql`lower(${t.title})`),
check('works_year_check', sql`${t.year} is null or ${t.year} between 0 and 2100`)
]
);
export const bookFiles = booksSchema.table(
'files',
{
id: id(),
workId: integer('work_id')
.notNull()
.references(() => bookWorks.id, { onDelete: 'cascade' }),
path: text('path').notNull(), // relative to /srv/books/library
format: text('format').notNull(), // epub | pdf | mobi | azw3 | djvu
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
sha256: text('sha256').notNull(),
md5: text('md5'),
source: text('source'), // 'gdrive' | 'textbooks-repo' | 'annas' | 'upload'
...timestamps
},
(t) => [
uniqueIndex('files_path_key').on(t.path),
uniqueIndex('files_sha256_key').on(t.sha256),
index('files_work_id_idx').on(t.workId)
]
);
export const bookShelves = booksSchema.table(
'shelves',
{
id: id(),
slug: text('slug').notNull(),
name: text('name').notNull(),
sortOrder: integer('sort_order').notNull().default(0),
...timestamps
},
(t) => [uniqueIndex('shelves_slug_key').on(t.slug)]
);
export const bookShelfWorks = booksSchema.table(
'shelf_works',
{
shelfId: integer('shelf_id')
.notNull()
.references(() => bookShelves.id, { onDelete: 'cascade' }),
workId: integer('work_id')
.notNull()
.references(() => bookWorks.id, { onDelete: 'cascade' }),
sortOrder: integer('sort_order').notNull().default(0),
addedAt: timestamptz('added_at').notNull().defaultNow()
},
(t) => [
primaryKey({ columns: [t.shelfId, t.workId] }),
index('shelf_works_work_id_idx').on(t.workId)
]
);
export const bookProgress = booksSchema.table(
'progress',
{
id: id(),
workId: integer('work_id')
.notNull()
.references(() => bookWorks.id, { onDelete: 'cascade' }),
fileId: integer('file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
locator: text('locator'), // EPUB CFI or 'page:123' for PDFs
page: integer('page'),
percent: real('percent').notNull().default(0),
startedAt: timestamptz('started_at'),
finishedAt: timestamptz('finished_at'),
...timestamps
},
(t) => [
uniqueIndex('progress_work_id_key').on(t.workId),
check('progress_percent_check', sql`${t.percent} between 0 and 100`)
]
);
export const bookNotes = booksSchema.table(
'notes',
{
id: id(),
workId: integer('work_id')
.notNull()
.references(() => bookWorks.id, { onDelete: 'cascade' }),
fileId: integer('file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
locator: text('locator'),
page: integer('page'),
highlight: text('highlight'), // the selected text, if any
note: text('note'),
color: text('color'),
...timestamps
},
(t) => [index('notes_work_id_idx').on(t.workId)]
);
export type BookWork = typeof bookWorks.$inferSelect;
export type NewBookWork = typeof bookWorks.$inferInsert;
export type BookFile = typeof bookFiles.$inferSelect;
export type BookShelf = typeof bookShelves.$inferSelect;
export type BookProgress = typeof bookProgress.$inferSelect;
export type BookNote = typeof bookNotes.$inferSelect;

View File

@@ -1,179 +0,0 @@
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'))
]
);

View File

@@ -12,4 +12,4 @@ export * from './schemas';
export * from './money-tracker'; export * from './money-tracker';
export * from './cs-prices'; export * from './cs-prices';
export * from './house-tracker'; export * from './house-tracker';
export * from './health'; export * from './books';

View File

@@ -1,17 +1,7 @@
import { import { boolean, check, date, index, integer, numeric, text, unique } from 'drizzle-orm/pg-core';
boolean,
check,
date,
index,
integer,
jsonb,
numeric,
text,
unique
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { moneySchema } from './schemas'; import { moneySchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers'; import { id, timestamps } from './helpers';
import { user } from './auth'; import { user } from './auth';
export const moneyAccounts = moneySchema.table( export const moneyAccounts = moneySchema.table(
@@ -25,22 +15,12 @@ export const moneyAccounts = moneySchema.table(
openingBalanceCents: integer('opening_balance_cents').notNull().default(0), openingBalanceCents: integer('opening_balance_cents').notNull().default(0),
kind: text('kind').notNull().default('spendable'), kind: text('kind').notNull().default('spendable'),
color: text('color'), 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 ...timestamps
}, },
(table) => [ (table) => [
unique().on(table.userId, table.name), unique().on(table.userId, table.name),
unique('accounts_external_uid_key').on(table.externalUid),
index().on(table.userId), 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')`)
] ]
); );
@@ -57,53 +37,9 @@ export const moneyTransactions = moneySchema.table(
date: date('date', { mode: 'string' }).notNull(), date: date('date', { mode: 'string' }).notNull(),
description: text('description').notNull(), description: text('description').notNull(),
amountCents: integer('amount_cents').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 ...timestamps
}, },
(table) => [ (table) => [index().on(table.userId, table.date, table.id), index().on(table.accountId)]
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( export const moneyRecurring = moneySchema.table(

View File

@@ -12,4 +12,4 @@ export const csSchema = pgSchema('cs');
export const animaldexSchema = pgSchema('animaldex'); export const animaldexSchema = pgSchema('animaldex');
export const siteSchema = pgSchema('site'); export const siteSchema = pgSchema('site');
export const metaSchema = pgSchema('meta'); export const metaSchema = pgSchema('meta');
export const healthSchema = pgSchema('health'); export const booksSchema = pgSchema('books');

View File

@@ -1,260 +0,0 @@
// 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
};
}

View File

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

View File

@@ -1,52 +0,0 @@
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();
});
});

View File

@@ -1,349 +0,0 @@
// 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;
});
}

View File

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

View File

@@ -1,5 +1,4 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { createHash } from 'node:crypto';
export const utilityKinds = ['electricity', 'gas', 'water'] as const; export const utilityKinds = ['electricity', 'gas', 'water'] as const;
export const cadences = ['weekly', 'monthly', 'yearly'] as const; export const cadences = ['weekly', 'monthly', 'yearly'] as const;
@@ -131,93 +130,21 @@ export type ImportedTransaction = {
account: string; account: string;
description: string; description: string;
amountCents: number; amountCents: number;
/** Stable per-row id ("csv:<hash>") so importing the same file twice adds nothing. */
externalId: string;
}; };
export type ImportFormat = 'tracker' | 'revolut'; export function parseTransactionImport(input: string): ImportedTransaction[] {
const rows = parseCsv(input);
function headerOf(rows: string[][]) { if (rows.length < 2 || rows.length > 20_001)
return rows[0].map((value) => throw new Error('The file must have a header and 1–20,000 rows.');
const header = rows.shift()!.map((value) =>
value value
.replace(/^\uFEFF/, '') .replace(/^\uFEFF/, '')
.trim() .trim()
.toLowerCase() .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)); const columns = ['date', 'account', 'description', 'amount'].map((key) => header.indexOf(key));
const accountColumn = columns[1]; if (columns.some((index) => index < 0))
if (columns[0] < 0 || columns[2] < 0 || columns[3] < 0 || (accountColumn < 0 && !defaultAccount)) throw new Error('Headers must include date, account, description and amount.');
throw new Error(
'Headers must include date, account, description and amount (or choose an account), or be a Revolut statement.'
);
const parsed: ImportedTransaction[] = []; const parsed: ImportedTransaction[] = [];
for (const [index, row] of rows.entries()) { for (const [index, row] of rows.entries()) {
if (row.length !== header.length) if (row.length !== header.length)
@@ -225,8 +152,7 @@ export function parseTransactionImport(
`Data row ${index + 1} has ${row.length} columns; expected ${header.length}. Quote values containing commas. No rows were imported.` `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 date = csvDate(row[columns[0]] ?? '');
const account = const account = (row[columns[1]] ?? '').trim();
(accountColumn >= 0 ? (row[accountColumn] ?? '') : '').trim() || defaultAccount?.trim() || '';
const description = (row[columns[2]] ?? '').trim(); const description = (row[columns[2]] ?? '').trim();
const amountCents = importCents(row[columns[3]]); const amountCents = importCents(row[columns[3]]);
if ( if (
@@ -238,64 +164,7 @@ export function parseTransactionImport(
amountCents == null amountCents == null
) )
throw new Error(`Invalid data in transaction ${index + 1}. No rows were imported.`); throw new Error(`Invalid data in transaction ${index + 1}. No rows were imported.`);
parsed.push({ parsed.push({ date, account, description, amountCents });
date,
account,
description,
amountCents,
externalId: importId([date, account.toLowerCase(), description, amountCents], seen)
});
} }
return parsed.sort((a, b) => a.date.localeCompare(b.date)); return parsed.sort((a, b) => a.date.localeCompare(b.date));
} }
export type ExistingImportRow = {
id: number;
accountId: number;
date: string;
amountCents: number;
externalId: string | null;
};
function daysApart(a: string, b: string) {
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
}
/**
* Splits imported rows into new ones and duplicates: same import id already stored, or an
* existing row on the same account with the same amount within ±1 day (each existing row
* can only absorb one imported row).
*/
export function planImport<
T extends { accountId: number; date: string; amountCents: number; externalId: string }
>(rows: T[], existing: ExistingImportRow[]) {
const ids = new Set(
existing.flatMap((row) => (row.externalId ? [`${row.accountId}:${row.externalId}`] : []))
);
const byKey = new Map<string, ExistingImportRow[]>();
for (const row of existing) {
const key = `${row.accountId}:${row.amountCents}`;
byKey.set(key, [...(byKey.get(key) ?? []), row]);
}
const claimed = new Set<number>();
const insert: T[] = [];
let duplicates = 0;
for (const row of rows) {
const idKey = `${row.accountId}:${row.externalId}`;
if (ids.has(idKey)) {
duplicates++;
continue;
}
const match = (byKey.get(`${row.accountId}:${row.amountCents}`) ?? [])
.filter((candidate) => !claimed.has(candidate.id) && daysApart(candidate.date, row.date) <= 1)
.sort((a, b) => daysApart(a.date, row.date) - daysApart(b.date, row.date))[0];
if (match) {
claimed.add(match.id);
duplicates++;
continue;
}
ids.add(idKey);
insert.push(row);
}
return { insert, duplicates };
}

View File

@@ -1,20 +0,0 @@
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);
});
});

View File

@@ -1,27 +0,0 @@
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}/`));
}

View File

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

View File

@@ -1,5 +1,4 @@
import { isAdmin } from '#lib/server/authorization.js'; import { isAdmin } from '#lib/server/authorization.js';
import { isOwner } from '#lib/server/owner.js';
import { dev } from '$app/env'; import { dev } from '$app/env';
import { siteEnvironment } from '#lib/title.js'; import { siteEnvironment } from '#lib/title.js';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
@@ -9,7 +8,6 @@ export const load: LayoutServerLoad = (event) => ({
? { id: event.locals.user.id, name: event.locals.user.name, image: event.locals.user.image } ? { id: event.locals.user.id, name: event.locals.user.name, image: event.locals.user.image }
: null, : null,
isAdmin: isAdmin(event), isAdmin: isAdmin(event),
isOwner: isOwner(event),
editable: isAdmin(event), editable: isAdmin(event),
version: process.env.APP_VERSION || 'dev', version: process.env.APP_VERSION || 'dev',
environment environment

View File

@@ -7,7 +7,7 @@
import { getSeo } from '#lib/seo.js'; import { getSeo } from '#lib/seo.js';
import CopyButton from '#lib/components/CopyButton.svelte'; import CopyButton from '#lib/components/CopyButton.svelte';
import ProjectIcon from '#lib/components/ProjectIcon.svelte'; import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import { projects, visibleTrackers } from '#lib/projects.js'; import { projects, trackers } from '#lib/projects.js';
import { Button, buttonVariants } from '#lib/components/ui/button/index.js'; import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
import * as Sheet from '#lib/components/ui/sheet/index.js'; import * as Sheet from '#lib/components/ui/sheet/index.js';
import ArrowRight from 'lucide-svelte/icons/arrow-right'; import ArrowRight from 'lucide-svelte/icons/arrow-right';
@@ -42,11 +42,13 @@
{ label: 'blog', href: '/blog' }, { label: 'blog', href: '/blog' },
{ label: 'lists', href: '/lists' } { label: 'lists', href: '/lists' }
] as const; ] as const;
type MenuItem = { name: string; subtitle: string; href: string; external: boolean }; const menus: Record<
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({ string,
{ label: string; items: readonly (typeof projects | typeof trackers)[number][] }
> = {
'/projects': { label: 'projects', items: projects }, '/projects': { label: 'projects', items: projects },
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) } '/trackers': { label: 'trackers', items: trackers }
}); };
const socialLinks = [ const socialLinks = [
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' }, { icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
{ icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' }, { icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' },

View File

@@ -3,7 +3,7 @@
import portrait from '#lib/assets/stan.jpg?url'; import portrait from '#lib/assets/stan.jpg?url';
import ProgressBar from '#lib/components/ProgressBar.svelte'; import ProgressBar from '#lib/components/ProgressBar.svelte';
import CatalogGrid from '#lib/components/CatalogGrid.svelte'; import CatalogGrid from '#lib/components/CatalogGrid.svelte';
import { projects, visibleTrackers } from '#lib/projects.js'; import { projects, trackers } from '#lib/projects.js';
import ArrowRight from 'lucide-svelte/icons/arrow-right'; import ArrowRight from 'lucide-svelte/icons/arrow-right';
import ExternalLink from 'lucide-svelte/icons/external-link'; import ExternalLink from 'lucide-svelte/icons/external-link';
import Mail from 'lucide-svelte/icons/mail'; import Mail from 'lucide-svelte/icons/mail';
@@ -114,10 +114,7 @@
>trackers <ArrowRight size={20} aria-hidden="true" /></a >trackers <ArrowRight size={20} aria-hidden="true" /></a
> >
</h2> </h2>
<CatalogGrid <CatalogGrid items={trackers} subtitleClass="mt-1 block text-base text-[var(--site-muted)]" />
items={visibleTrackers(data.isOwner)}
subtitleClass="mt-1 block text-base text-[var(--site-muted)]"
/>
</section> </section>
<section aria-labelledby="lists-heading" class="pt-14 sm:pt-20"> <section aria-labelledby="lists-heading" class="pt-14 sm:pt-20">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,36 +0,0 @@
<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>

View File

@@ -1,8 +1,6 @@
<script lang="ts"> <script lang="ts">
import CatalogGrid from '#lib/components/CatalogGrid.svelte'; import CatalogGrid from '#lib/components/CatalogGrid.svelte';
import { visibleTrackers } from '#lib/projects.js'; import { trackers } from '#lib/projects.js';
let { data } = $props();
</script> </script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16"> <div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
@@ -10,5 +8,5 @@
<p class="mb-10 text-[var(--site-muted)]"> <p class="mb-10 text-[var(--site-muted)]">
Personal data I keep track of: games, money, houses, prices, animals and lists. Personal data I keep track of: games, money, houses, prices, animals and lists.
</p> </p>
<CatalogGrid items={visibleTrackers(data.isOwner)} /> <CatalogGrid items={trackers} />
</div> </div>

View File

@@ -1,11 +0,0 @@
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 {};
};

View File

@@ -1,110 +0,0 @@
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 };
}
};

View File

@@ -1,486 +0,0 @@
<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>

View File

@@ -1,51 +0,0 @@
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');
};

View File

@@ -1,17 +0,0 @@
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();
});