17 Commits

Author SHA1 Message Date
b77c3f8cf5 Tasks: import TickTick backup CSV (csv: keys, matched against API rows, rekeyed when the API returns them)
All checks were successful
Deploy production / deploy (push) Successful in 2m29s
2026-10-24 03:28:14 +00:00
1754ea9316 Tasks: ignore placeholder checklist completion dates from TickTick
All checks were successful
Deploy production / deploy (push) Successful in 2m34s
2026-10-24 03:10:53 +00:00
6a127de950 Tasks: TickTick history sync (export + idempotent import, start_at/time_zone); trackers ordered tasks, money, books with single-noun names
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
2026-10-24 03:05:05 +00:00
9d08e6af23 Lists: owner-only database browser/editor for all tables (meta.edit_log audit)
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
2026-10-24 02:52:18 +00:00
941a9f16e1 data browser wip 2026-10-24 02:52:18 +00:00
c8205e474e Money: wish list sorted by tier then price (desc), theme-aware tier colours
All checks were successful
Deploy production / deploy (push) Successful in 2m31s
2026-10-24 02:43:31 +00:00
81ee02f11a books reader: fix the error when leaving a PDF (back button)
Some checks failed
Deploy production / deploy (push) Has been cancelled
pdf.js 6 removed PDFDocumentProxy.destroy(), so the PdfReader teardown threw
"destroy is not a function" on every unmount, breaking the client-side
navigation back to the book page (and sometimes showing the error page).
Close the document through its loading task instead (closePdf), which also
cancels a load still in flight. Test covers the pdf.js API and closePdf.
2026-10-24 02:40:32 +00:00
eb80a830f5 Task + goal tracker: private tasks schema, /trackers/tasks page, idempotent seed import (daisy-import)
All checks were successful
Deploy production / deploy (push) Successful in 2m38s
2026-10-24 02:37:48 +00:00
988c6c31a9 Money: move /projects/money-tracker to /trackers/money, rename to Money, 308 redirects from old paths (bank-callback query kept)
All checks were successful
Deploy production / deploy (push) Successful in 2m49s
2026-10-24 02:37:07 +00:00
2af279e8a9 Money tracker: vertical month/year gridlines on wealth chart, keep range in ?range= URL param
All checks were successful
Deploy production / deploy (push) Successful in 2m36s
2026-10-24 02:25:13 +00:00
faeb76a7e7 books reader: zoom, 1/2-page spreads, scroll vs paged mode
Some checks failed
Deploy production / deploy (push) Has been cancelled
- Zoom buttons + Ctrl/Cmd +/-/0, Ctrl+wheel/trackpad pinch, touch pinch on PDFs;
  PDF scales the pdf.js render (sharp re-render after it settles), EPUB scales text.
  Remembered per format, and per book.
- One/two pages side by side: PDF rows with the cover alone, foliate max-column-count.
- Scroll vs Pages mode for both engines; PDF paged shows one page/spread fitted to the
  screen, prev/next move a whole spread; spreads stack when scrolling.
- Reading position is kept across zoom/spread/mode changes; progress keeps saving.
- layout.ts helpers with unit tests; old flow/fontSize/pdfZoom settings migrate.
2026-10-24 02:24:35 +00:00
a022c45499 Money tracker: accounts can be left out of total wealth (include_in_total)
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
Adds money.accounts.include_in_total (default true). Excluded accounts keep their own balance and line, but are left out of net worth, invested, this month, the projection and the total/All accounts lines.
2026-10-24 02:14:03 +00:00
6f82d1350e deploy: write the books compose override in the deploy account home
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
Run #79 failed at "sudo tee /opt/personal-website/compose.books.yml": the deploy
account may only sudo-tee .env. Write the override to ~deploy instead; the
compose CLI runs as root and reads it from there.
2026-10-24 02:04:12 +00:00
7a039b23f4 deploy: check the books dir from a root container (deploy user cannot traverse /home/stan)
Some checks failed
Deploy production / deploy (push) Failing after 2m12s
Run #78 skipped the /books mount because [ -d /home/stan/books/library ] runs as
the deploy account, which has no access to /home/stan (0750). Check inside a
container with the dir bind-mounted instead; the mount itself is done by dockerd.
2026-10-24 01:59:11 +00:00
8445eef3cf books migration: only call meta.sync() when permitted; deploy runs it as the app role
All checks were successful
Deploy production / deploy (push) Successful in 2m35s
Run #77 failed in db-migrate with "permission denied for function sync": in prod
meta.sync() is granted to personal_app/personal_ddl, not to the schema owner
(personal) that runs migrations. The migration still registers the books tables in
meta.tables and syncs when it can (dev); deploy-remote.sh now runs
scripts/db-meta-sync.ts as DB_URL after migrating. Verified on a prod clone with
SET ROLE personal / personal_app.
2026-10-24 01:53:30 +00:00
1d4cfd58c5 books: import zod as a namespace so tests pass under the Bun runtime in CI
Some checks failed
Deploy production / deploy (push) Failing after 2m20s
The Docker build runs vitest via bun (no Node in oven/bun), where the named
`{ z }` import from zod 4 resolved to undefined; the rest of the repo already
uses `import * as z from 'zod'`.
2026-10-24 01:47:10 +00:00
763d7881fb Money tracker: Knab CSV import, multi-bank linking, sum reconcile, history range
Some checks failed
Deploy production / deploy (push) Failing after 23s
- Knab statement exports: per-account ids (knab:<account>:<Referentie>), routed by IBAN,
  transfers between own accounts merged into one tracker account are dropped (interest kept)
- Import: multiple files, hand-entered twins get the import id, hand entries equal to the
  sum of 2-3 payments within a day are replaced by them
- bank-link --bank revolut|abn|knab with per-bank default account; ASPSP notes printed
- bank-sync: longest history on an account first sync (--full to force), sum reconcile,
  per-session status so one bank failing does not mark the others
- Timeline: 1 year / 3 years / All history range
2026-10-24 01:37:40 +00:00
94 changed files with 67198 additions and 349 deletions

View File

@@ -255,14 +255,14 @@ Files are read from `BOOKS_ROOT` (default `/books` when it exists, otherwise `~/
- `BOOKS_ROOT/library/<books.files.path>` for book files (written by `scripts/books-ingest.py`)
- `BOOKS_ROOT/covers/<books.works.cover_path>` for downloaded covers
In production, `scripts/deploy-remote.sh` writes `/opt/personal-website/compose.books.yml`, which
In production, `scripts/deploy-remote.sh` writes `~deploy/compose.books.yml` (the deploy account may only sudo-tee `/opt/personal-website/.env`), which
bind-mounts the host's `BOOKS_HOST_DIR` (default `/home/stan/books`) read-only at `/books` and sets
`BOOKS_ROOT=/books`. Without a `library` directory there the deploy still succeeds, but files can't
be opened.
## Money tracker
## Money
`/projects/money-tracker` is restricted to administrator accounts. Its tables
`/trackers/money` (formerly `/projects/money-tracker`, which now redirects) is restricted to administrator accounts. Its tables
are created by `npm run db:migrate:money-tracker`; apply that migration to each
environment before deploying the new route. The deployment preflight checks for
all nine tracker tables and the projection columns.

View File

@@ -0,0 +1,643 @@
{
"source": "daisy-import",
"areas": [
{
"slug": "deadlines",
"name": "Deadlines and dates",
"sortOrder": 10
},
{
"slug": "house",
"name": "House and moving",
"description": "Jan–Feb 2027 with Juhee",
"sortOrder": 20
},
{
"slug": "kibi",
"name": "Kibi",
"sortOrder": 30
},
{
"slug": "admin",
"name": "Phone, subscriptions and admin",
"sortOrder": 40
},
{
"slug": "clients",
"name": "Clients",
"sortOrder": 50
},
{
"slug": "website",
"name": "Website (stanrunge.dev)",
"sortOrder": 60
},
{
"slug": "setup",
"name": "Setup and security",
"description": "Perma evolvable setup, before 2027",
"sortOrder": 70
},
{
"slug": "home",
"name": "Home",
"sortOrder": 80
},
{
"slug": "health",
"name": "Health and self-care",
"sortOrder": 90
},
{
"slug": "reading",
"name": "Reading",
"sortOrder": 100
},
{
"slug": "planning",
"name": "Planning and goals",
"sortOrder": 110
}
],
"goals": [
{
"title": "€10k net/month into own Revolut by 2030",
"area": "planning",
"targetDate": "2030-01-01",
"metric": "Net income per month",
"metricUnit": "EUR",
"metricTarget": 10000
},
{
"title": "Own a really nice Ford Mustang GT"
},
{
"title": "PC overhaul",
"notes": "Top AMD X3D CPU, RTX 5090.",
"metric": "Budget",
"metricUnit": "EUR",
"metricTarget": 10000
},
{
"title": "Organise goals into a plan",
"area": "planning",
"notes": "Social media, Grok Bot, IT company."
},
{
"title": "Perma evolvable setup foundation ready before 2027",
"area": "setup",
"targetDate": "2026-12-31"
},
{
"title": "Get in shape long term",
"area": "health"
}
],
"tasks": [
{
"area": "deadlines",
"title": "Jan Loman printer visit",
"status": "done",
"notes": "Settled.",
"due": "2026-10-24",
"dueTime": "14:00"
},
{
"area": "deadlines",
"title": "Call BackHome about Kibi's chip",
"status": "todo",
"due": "2026-10-26",
"dueTime": "10:00",
"tags": ["kibi", "call"]
},
{
"area": "deadlines",
"title": "Cargill collective health insurance (OHRA, code 114)",
"status": "todo",
"notes": "Needs current insurer + premium to compare.",
"due": "2026-12-31",
"tags": ["insurance"]
},
{
"area": "deadlines",
"title": "Reply to mom about insurance info",
"status": "todo",
"notes": "Waiting since Oct 9.",
"tags": ["insurance"]
},
{
"area": "deadlines",
"title": "Stop zorgtoeslag from Jan 2027",
"status": "todo",
"due": "2027-01-01",
"reminderAt": "2026-12-07T09:00:00+01:00",
"tags": ["admin"]
},
{
"area": "deadlines",
"title": "Supermarket product list",
"status": "todo",
"reminderAt": "2026-10-24T11:50:00+02:00"
},
{
"area": "deadlines",
"title": "Book haircut",
"status": "todo",
"notes": "Around Nov 14.",
"due": "2026-11-14"
},
{
"area": "house",
"title": "House overview",
"status": "in_progress",
"flagged": true,
"notes": "Applied only to Graaf Hendrikstraat 16 so far."
},
{
"area": "house",
"title": "React to house options",
"status": "todo",
"notes": "Raam €1,475; Burg. van Dijkesingel 214-B; Groenhovenweg 161; Spoorstraat €795; Noothoven van Goorstraat 1-206."
},
{
"area": "house",
"title": "Tell Daisy budget, move date, must-haves, Funda/Pararius applications",
"status": "todo"
},
{
"area": "house",
"title": "Mijndak Midden-Holland sign-up",
"status": "todo",
"flagged": true,
"notes": "€23 option."
},
{
"area": "house",
"title": "Sign up: socialehuurwoning.nl, Vesteda Goudshof, VBT, Viramah, agency Jesse's mom uses",
"status": "todo"
},
{
"area": "house",
"title": "House tracker/scraper on the site",
"status": "todo",
"tags": ["website"]
},
{
"area": "house",
"title": "Update address everywhere",
"status": "todo",
"notes": "Checklist ready."
},
{
"area": "house",
"title": "UniFi network for the new place",
"status": "todo",
"tags": ["network"]
},
{
"area": "kibi",
"title": "Food ordered",
"status": "done"
},
{
"area": "kibi",
"title": "Chip scanner ordered",
"status": "done"
},
{
"area": "kibi",
"title": "Chip number found",
"status": "done"
},
{
"area": "kibi",
"title": "Chip owner change at BackHome",
"status": "todo",
"notes": "Still 'Kwiebus' under E Zwart."
},
{
"area": "kibi",
"title": "Call vet about Kibi's patient card",
"status": "todo",
"tags": ["call"]
},
{
"area": "kibi",
"title": "GPS tracker",
"status": "todo",
"notes": "Dad may have done it already."
},
{
"area": "kibi",
"title": "Dash KIBI",
"status": "todo",
"flagged": true,
"notes": "Meaning unclear, ask Stan."
},
{
"area": "admin",
"title": "Basic-Fit cancelled",
"status": "done"
},
{
"area": "admin",
"title": "Ohra inboedelverzekering cancelled",
"status": "done"
},
{
"area": "admin",
"title": "Zorgtoeslag income updated for 2026",
"status": "done"
},
{
"area": "admin",
"title": "Move number to Revolut eSIM, then iPad eSIM, cancel Odido",
"status": "in_progress"
},
{
"area": "admin",
"title": "Move SIM + WhatsApp from iPhone to Pixel 11 Pro XL",
"status": "todo"
},
{
"area": "admin",
"title": "Check Oasen and Delta Energie fully cancelled",
"status": "todo"
},
{
"area": "admin",
"title": "Cancel other unused subscriptions",
"status": "todo"
},
{
"area": "admin",
"title": "Merge banks into Revolut",
"status": "todo"
},
{
"area": "clients",
"title": "Quote dack €450 for vet clinic site fix",
"status": "done",
"tags": ["dack"]
},
{
"area": "clients",
"title": "Reply to dack",
"status": "done",
"tags": ["dack"]
},
{
"area": "clients",
"title": "Cloudflare DNS/SSL for dack",
"status": "in_progress",
"tags": ["dack"],
"notes": "Grind-week goal."
},
{
"area": "clients",
"title": "Send dack's invoice via Moneybird",
"status": "todo",
"tags": ["dack"]
},
{
"area": "clients",
"title": "Cheap shared WordPress hosting for dack's clients on own VMs",
"status": "todo",
"tags": ["dack"]
},
{
"area": "clients",
"title": "Karolina's Wix site, Master Purpose (NL/PL)",
"status": "todo",
"tags": ["karolina"]
},
{
"area": "clients",
"title": "Wix Studio + Velo/CLI so an AI can edit code",
"status": "todo",
"tags": ["karolina"]
},
{
"area": "website",
"title": "DB reorganised into schemas, staging retired",
"status": "done"
},
{
"area": "website",
"title": "Energy tracker (NED data) live",
"status": "done"
},
{
"area": "website",
"title": "Health tracker (private) + 15 RIVM vaccinations",
"status": "done"
},
{
"area": "website",
"title": "Book tracker, reader, 362 books live",
"status": "done"
},
{
"area": "website",
"title": "Reader zoom, 2-page view, scroll/paging",
"status": "in_progress"
},
{
"area": "website",
"title": "Money tracker with MoneyMan",
"status": "in_progress"
},
{
"area": "website",
"title": "NED backfill",
"status": "in_progress"
},
{
"area": "website",
"title": "Music listening tracker + now-playing",
"status": "todo"
},
{
"area": "website",
"title": "Task/goal tracker to replace TickTick",
"status": "in_progress"
},
{
"area": "website",
"title": "Hardware DB (current PC parts first)",
"status": "todo"
},
{
"area": "website",
"title": "List of every organisation holding my data and what they know",
"status": "todo"
},
{
"area": "website",
"title": "Move to SvelteKit 3",
"status": "todo"
},
{
"area": "website",
"title": "Juhee website data",
"status": "todo"
},
{
"area": "setup",
"title": "AWS cleanup ($0/month)",
"status": "done",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Google Cloud cleanup",
"status": "done",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "stan-vm hardened (ufw, Tailscale-only SSH)",
"status": "done",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Nightly restic backups to B2 + Storage Box",
"status": "done",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "WhatsApp bridge on stan-vm",
"status": "done",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "gcloud + moneybird-cli on stan-vm",
"status": "done",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Domains Squarespace to Cloudflare",
"status": "in_progress",
"goal": "Perma evolvable setup foundation ready before 2027",
"notes": "Both transfers pending.",
"due": "2026-10-29"
},
{
"area": "setup",
"title": "Syncthing: Introducer + Auto Accept on Arch and Windows",
"status": "in_progress",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Backup follow-up: restic passwords into 1Password",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"due": "2026-10-24",
"dueTime": "11:00"
},
{
"area": "setup",
"title": "Delete empty B2 bucket",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"due": "2026-10-25"
},
{
"area": "setup",
"title": "Rotate weak admin secret + DB password",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"tags": ["security"]
},
{
"area": "setup",
"title": "1Password overhaul",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"notes": "Imports, 2FA, passkeys, dedupe, delete unused accounts.",
"tags": ["security"]
},
{
"area": "setup",
"title": "Moneybird API token, then clean up Moneybird",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Authentik SSO on stan-vm",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "vash-prod memory warnings + finish security audit",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"tags": ["security"]
},
{
"area": "setup",
"title": "Remove old grok-bot devices from Tailscale",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Close empty GCP billing account; decide on AWS 'Vash Software'",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"notes": "AWS part optional."
},
{
"area": "setup",
"title": "Raise WSL RAM",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"notes": "Sees 8 of 16 GB."
},
{
"area": "setup",
"title": "Home server rebuild with ZFS",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Analytics, monitoring, alerts across devices",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Mouse sensitivity cm/360 across devices",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Buy Logitech G840 XL mousepad",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"tags": ["buy"]
},
{
"area": "setup",
"title": "Router and networking setup",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"tags": ["network"]
},
{
"area": "setup",
"title": "J5 Hexagon research, then move wachtboden",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "Agent-deck + AI/Linux dev setup",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027"
},
{
"area": "setup",
"title": "MITM/certificate lab",
"status": "todo",
"goal": "Perma evolvable setup foundation ready before 2027",
"notes": "Idea.",
"tags": ["idea"]
},
{
"area": "home",
"title": "Unpack boxes; organise batteries and tools; buy thermometer",
"status": "todo"
},
{
"area": "home",
"title": "Clean desk, laundry, clothes",
"status": "todo"
},
{
"area": "home",
"title": "Connect vinyl player, printer, monitor, switch, osu! pad, tablet, fridge, microwave",
"status": "todo"
},
{
"area": "home",
"title": "Hue lights + Home Assistant + Apple Home",
"status": "todo"
},
{
"area": "home",
"title": "Fan controller",
"status": "todo"
},
{
"area": "home",
"title": "Opa's stuff (WhatsApp, sensor install)",
"status": "todo"
},
{
"area": "health",
"title": "Getting in shape with Doc",
"status": "in_progress",
"notes": "Lesson 1 done; ~2,300–2,400 kcal and 160–190 g protein a day.",
"goal": "Get in shape long term"
},
{
"area": "health",
"title": "Join the gym my parents use",
"status": "todo",
"goal": "Get in shape long term"
},
{
"area": "health",
"title": "Brush teeth twice a day, shower, hair care",
"status": "todo",
"tags": ["habit"]
},
{
"area": "health",
"title": "Dentist checkup",
"status": "todo"
},
{
"area": "health",
"title": "Pick hardware for Withings/Garmin syncs",
"status": "todo"
},
{
"area": "health",
"title": "Curaçao vaccines",
"status": "todo",
"notes": "Via Doc."
},
{
"area": "reading",
"title": "Read 'De meeste mensen deugen' (Bregman)",
"status": "todo"
},
{
"area": "reading",
"title": "Re-download 'Haves and Have-Nots'",
"status": "todo"
},
{
"area": "reading",
"title": "Book questions: duplicates, 6 unknown files, Nick's repo 404",
"status": "todo"
},
{
"area": "planning",
"title": "Schedule sync (paper agenda, TickTick, Daisy)",
"status": "todo",
"goal": "Organise goals into a plan"
}
]
}

View File

@@ -0,0 +1,7 @@
[
{ "ticktickId": "6ad52191ebcd7400000000ce", "title": "Jan Loman printer visit" },
{ "ticktickId": "690d2fd98c824268f297a676", "title": "Router and networking setup" },
{ "ticktickId": "6844769df471d103461fa9f1", "title": "Home server rebuild with ZFS" },
{ "ticktickId": "6ab6f32759b6090811e91a00", "title": "Dentist checkup" },
{ "ticktickId": "68225a8dbfe81104eac49a7a", "title": "Merge banks into Revolut" }
]

View File

@@ -21,7 +21,8 @@ export default defineConfig({
'site',
'meta',
'health',
'books'
'books',
'tasks'
],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true,

View File

@@ -170,7 +170,11 @@ BEGIN
('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();
-- meta.sync() is executable by personal_app/personal_ddl, not by the migration role
-- (personal) in prod; deploy-remote.sh runs it as the app role after migrating.
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
PERFORM meta.sync();
END IF;
END IF;
END $$;

View File

@@ -0,0 +1 @@
ALTER TABLE "money"."accounts" ADD COLUMN "include_in_total" boolean DEFAULT true NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,142 @@
CREATE SCHEMA "tasks";
--> statement-breakpoint
CREATE TABLE "tasks"."activity" (
"id" serial PRIMARY KEY,
"task_id" integer,
"goal_id" integer,
"kind" text NOT NULL,
"from_status" text,
"to_status" text,
"note" text,
"value" numeric(14,2),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "activity_target_check" CHECK ("task_id" IS NOT NULL OR "goal_id" IS NOT NULL),
CONSTRAINT "activity_kind_check" CHECK ("kind" IN ('created','status','edit','note','progress'))
);
--> statement-breakpoint
CREATE TABLE "tasks"."areas" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL,
"name" text NOT NULL,
"description" text,
"color" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"archived_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 "areas_slug_check" CHECK ("slug" ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
CONSTRAINT "areas_name_check" CHECK (length(btrim("name")) > 0)
);
--> statement-breakpoint
CREATE TABLE "tasks"."goals" (
"id" serial PRIMARY KEY,
"title" text NOT NULL,
"notes" text,
"status" text DEFAULT 'active' NOT NULL,
"area_id" integer,
"target_date" date,
"metric" text,
"metric_unit" text,
"metric_target" numeric(14,2),
"metric_current" numeric(14,2),
"flagged" boolean DEFAULT false NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"source" text,
"achieved_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 "goals_title_check" CHECK (length(btrim("title")) > 0),
CONSTRAINT "goals_status_check" CHECK ("status" IN ('active','achieved','dropped'))
);
--> statement-breakpoint
CREATE TABLE "tasks"."tasks" (
"id" serial PRIMARY KEY,
"title" text NOT NULL,
"notes" text,
"status" text DEFAULT 'todo' NOT NULL,
"priority" smallint DEFAULT 0 NOT NULL,
"flagged" boolean DEFAULT false NOT NULL,
"due_date" date,
"due_time" time,
"reminder_at" timestamp with time zone,
"recurrence" text,
"area_id" integer,
"goal_id" integer,
"parent_id" integer,
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"source" text,
"external_id" text,
"started_at" timestamp with time zone,
"completed_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 "tasks_title_check" CHECK (length(btrim("title")) > 0),
CONSTRAINT "tasks_status_check" CHECK ("status" IN ('todo','in_progress','done','dropped')),
CONSTRAINT "tasks_priority_check" CHECK ("priority" BETWEEN 0 AND 3),
CONSTRAINT "tasks_parent_check" CHECK ("parent_id" IS NULL OR "parent_id" <> "id"),
CONSTRAINT "tasks_time_check" CHECK ("due_time" IS NULL OR "due_date" IS NOT NULL)
);
--> statement-breakpoint
CREATE INDEX "activity_task_idx" ON "tasks"."activity" ("task_id","created_at");--> statement-breakpoint
CREATE INDEX "activity_goal_idx" ON "tasks"."activity" ("goal_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "areas_slug_key" ON "tasks"."areas" ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "goals_title_key" ON "tasks"."goals" (lower("title"));--> statement-breakpoint
CREATE INDEX "tasks_area_idx" ON "tasks"."tasks" ("area_id");--> statement-breakpoint
CREATE INDEX "tasks_goal_idx" ON "tasks"."tasks" ("goal_id");--> statement-breakpoint
CREATE INDEX "tasks_parent_idx" ON "tasks"."tasks" ("parent_id");--> statement-breakpoint
CREATE INDEX "tasks_status_due_idx" ON "tasks"."tasks" ("status","due_date");--> statement-breakpoint
CREATE INDEX "tasks_tags_idx" ON "tasks"."tasks" USING gin ("tags");--> statement-breakpoint
CREATE UNIQUE INDEX "tasks_source_external_key" ON "tasks"."tasks" ("source","external_id") WHERE "external_id" IS NOT NULL;--> statement-breakpoint
ALTER TABLE "tasks"."activity" ADD CONSTRAINT "activity_task_id_tasks_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"."tasks"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "tasks"."activity" ADD CONSTRAINT "activity_goal_id_goals_id_fkey" FOREIGN KEY ("goal_id") REFERENCES "tasks"."goals"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "tasks"."goals" ADD CONSTRAINT "goals_area_id_areas_id_fkey" FOREIGN KEY ("area_id") REFERENCES "tasks"."areas"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_area_id_areas_id_fkey" FOREIGN KEY ("area_id") REFERENCES "tasks"."areas"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_goal_id_goals_id_fkey" FOREIGN KEY ("goal_id") REFERENCES "tasks"."goals"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_parent_id_tasks_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "tasks"."tasks"("id") ON DELETE CASCADE;
--> statement-breakpoint
-- Hand-written below this line (not generated by drizzle-kit).
-- updated_at triggers, same as every other core table.
DO $$
DECLARE t text;
BEGIN
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
FOREACH t IN ARRAY ARRAY['areas','goals','tasks'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON tasks.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END IF;
END $$;
--> statement-breakpoint
-- Privacy: like health, the task tracker is readable/writable by the app role only
-- (no personal_ro grant). Grants only run on prod, where migrations run as `personal`.
REVOKE ALL ON SCHEMA tasks 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 tasks TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tasks TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA tasks TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA tasks
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA tasks
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
END $$;
--> statement-breakpoint
-- Registered in meta.tables (structure only; the lists GUI only shows kind = 'list').
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 ('tasks','tasks','tasks-tasks','Tasks','core',true,true,1000),
('tasks','goals','tasks-goals','Goals','core',true,false,1000),
('tasks','areas','tasks-areas','Task areas','core',true,false,1000),
('tasks','activity','tasks-activity','Task activity','core',false,false,1000)
ON CONFLICT DO NOTHING;
-- deploy-remote.sh runs meta.sync() as the app role after migrating.
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
PERFORM meta.sync();
END IF;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
CREATE TABLE "meta"."edit_log" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "meta"."edit_log_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"at" timestamp with time zone DEFAULT now() NOT NULL,
"user_id" text,
"user_email" text,
"schema_name" text NOT NULL,
"table_name" text NOT NULL,
"action" text NOT NULL,
"pk" jsonb,
"before" jsonb,
"after" jsonb,
CONSTRAINT "edit_log_action_check" CHECK (action IN ('insert','update','delete','import','add_column'))
);
--> statement-breakpoint
CREATE INDEX "edit_log_table_at_index" ON "meta"."edit_log" ("schema_name","table_name","at");--> statement-breakpoint
-- Hand-written below this line (not generated by drizzle-kit).
-- The app role appends to and reads the log; nobody updates or deletes through the app.
-- personal_ro gets no access: before/after values can hold private (health) data.
-- Grants only run on prod (as `personal`); the dev DB is owned by personal_dev.
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT ON meta.edit_log TO personal_app;
GRANT USAGE ON SEQUENCE meta.edit_log_id_seq TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
REVOKE ALL ON meta.edit_log FROM personal_ro;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
ALTER TABLE "tasks"."tasks" ADD COLUMN "start_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "tasks"."tasks" ADD COLUMN "time_zone" text;

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,16 @@
// Link Revolut (personal + joint) to the money tracker through Enable Banking.
// Link a bank to the money tracker through Enable Banking. Each bank gets its own session;
// linking one never touches another bank's session.
//
// bun --env-file=$HOME/.config/money-sync.env scripts/bank-link.ts interactive consent
// bun --env-file=$HOME/.config/money-sync.env scripts/bank-link.ts Revolut (personal + joint)
// ... scripts/bank-link.ts --bank abn ABN AMRO -> "ABN Amro"
// ... scripts/bank-link.ts --bank knab Knab -> "Knab"
// ... 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>
// ... scripts/bank-link.ts --aspsp '<ASPSP name>' --account <Tracker account> --country NL --user <id>
//
// Then sync within the hour: the first sync of an account asks for the longest history the
// bank serves, and most banks only serve ~90 days once consent is older than an hour.
//
// Needs ENABLEBANKING_APP_ID, ENABLEBANKING_KEY_PATH, DB_URL (and optionally
// ENABLEBANKING_REDIRECT_URL). Never prints the private key.
@@ -33,11 +39,37 @@ args.forEach((arg, i) => {
overrides[key.replace(/\s/g, '').toUpperCase()] = rest.join('=');
});
// Enable Banking ASPSP names (GET /aspsps?country=NL) and the tracker account they feed.
const BANKS: Record<string, { aspsp: string; account: string; hint: string }> = {
revolut: {
aspsp: 'Revolut',
account: 'Revolut',
hint: 'Tick BOTH your personal account and the joint account with Juhee.'
},
abn: {
aspsp: 'ABN AMRO',
account: 'ABN Amro',
hint: 'Log in with the ABN AMRO app or identifier and approve your current account.'
},
knab: {
aspsp: 'Knab',
account: 'Knab',
hint: 'Approve your Knab current account (savings accounts are not needed).'
}
};
const bankKey = (value('--bank') ?? 'revolut').toLowerCase().replace(/[\s-]|amro/g, '');
const preset = value('--aspsp') && !value('--bank') ? undefined : BANKS[bankKey];
if (!preset && !value('--aspsp'))
throw new Error(`Unknown --bank ${value('--bank')}; use ${Object.keys(BANKS).join(', ')}`);
const client = createEnableBankingClient(configFromEnv());
const aspsp = { name: value('--aspsp') ?? 'Revolut', country: value('--country') ?? 'NL' };
const aspsp = {
name: value('--aspsp') ?? preset!.aspsp,
country: value('--country') ?? 'NL'
};
const defaultAccount = value('--account') ?? preset?.account ?? aspsp.name;
const redirectUrl =
process.env.ENABLEBANKING_REDIRECT_URL ??
'https://stanrunge.dev/projects/money-tracker/bank-callback';
'https://stanrunge.dev/projects/money-tracker/bank-callback'; // registered with Enable Banking; 308s to /trackers/money/bank-callback with the query kept
const app = await client.application();
console.log(
@@ -47,7 +79,15 @@ 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`
`${bank.name} ${bank.country}${bank.bic ? ' ' + bank.bic : ''}: max consent ` +
`${Math.floor(bank.maximum_consent_validity / 86400)} days${bank.beta ? ' (beta)' : ''}` +
(bank.required_psu_headers?.length
? `; requires PSU headers ${bank.required_psu_headers.join(', ')} (background syncs ` +
'run as "PSU not present", which PSD2 limits to ~4 calls a day per account)'
: '')
);
console.log(
`Linked accounts go to tracker account "${defaultAccount}" unless --map says otherwise.`
);
if (flag('--check')) process.exit(0);
@@ -63,8 +103,8 @@ if (!pasted) {
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(`\n1. Open this link and approve access at ${bank.name}.`);
console.log(` ${preset?.hint ?? 'Approve the account(s) you want to track.'}\n`);
console.log(' ' + auth.url + '\n');
console.log(`2. You'll land on ${redirectUrl}?code=...`);
console.log(
@@ -88,7 +128,10 @@ try {
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);
const plan = planAccountMapping(session.accounts, tracker, overrides, {
defaultAccount,
joint: bank.name.toLowerCase() === 'revolut'
});
for (const { bank: account, tracker: target, reason } of plan) {
const iban = account.account_id?.iban ?? '(no IBAN)';
console.log(
@@ -103,8 +146,14 @@ try {
process.exit(1);
}
await saveSession(sql, userId, session, plan);
const linked = plan.filter((p) => p.tracker).map((p) => p.tracker!.name);
const only = linked.length === 1 ? ` --account '${linked[0]}'` : '';
console.log('Saved. Within the next hour (full history is only served right after consent):');
console.log(
'Saved. Next: bun --env-file=$HOME/.config/money-sync.env scripts/bank-sync.ts --reconcile --dry-run'
` bun --env-file=$HOME/.config/money-sync.env scripts/bank-sync.ts --reconcile --dry-run${only}`
);
console.log(
` bun --env-file=$HOME/.config/money-sync.env scripts/bank-sync.ts --reconcile${only}`
);
} finally {
rl.close();

View File

@@ -7,6 +7,8 @@
// 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)
// --full ask for the longest history the bank serves (automatic on an
// account's first sync; only works within an hour of consent at most banks)
// --account <name> only this tracker account
//
// Dedupe: unique (account_id, external_id); re-running is always safe.
@@ -31,12 +33,15 @@ try {
dryRun,
reconcile: args.includes('--reconcile'),
from,
full: args.includes('--full'),
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}` +
`matched to existing ${r.matched}, replaced sum entries ${r.absorbed}, ` +
`already synced ${r.alreadyKnown}` +
(r.historyFrom ? `, history from ${r.historyFrom}` : '') +
(r.balance ? `, bank balance ${r.balance}` : '') +
(r.error ? `\n ERROR ${r.error}` : '')
);

19
scripts/db-meta-sync.ts Normal file
View File

@@ -0,0 +1,19 @@
// Refreshes meta.columns from the live catalog for every table in meta.tables
// (meta.sync() is SECURITY DEFINER and granted to the app role). Idempotent.
// bun scripts/db-meta-sync.ts uses DB_URL (personal_app)
import postgres from 'postgres';
const url = process.env.DB_URL;
if (!url) throw new Error('Set DB_URL (app role)');
const client = postgres(url, { max: 1, connect_timeout: 10, onnotice: () => {} });
try {
const [registry] = await client`select to_regprocedure('meta.sync(integer)') is not null as ok`;
if (!registry.ok) {
console.log('meta.sync() not present; skipping');
} else {
const [row] = await client`select meta.sync() as added`;
console.log(`meta.sync(): ${row.added} column(s) added`);
}
} finally {
await client.end();
}

View File

@@ -129,6 +129,28 @@ try {
health.public_usage
)
throw new Error(`health schema grants are wrong: ${JSON.stringify(health)}`);
// Private task tracker: same rules as health (app role read/write, personal_ro none).
const [tasks] = 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', 'tasks', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'tasks' and c.relkind = 'r'
`;
if (
Number(tasks.tables) !== 4 ||
Number(tasks.app_rw) !== 4 ||
Number(tasks.ro_read) !== 0 ||
tasks.public_usage
)
throw new Error(`tasks schema grants are wrong: ${JSON.stringify(tasks)}`);
// 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
@@ -138,7 +160,7 @@ try {
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'}`
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
);
const [catalog] = await client`
select

View File

@@ -19,14 +19,31 @@ environment_name=production
image="git.stanrunge.dev/stan/website:$sha"
compose="$directory/compose.prod.yml"
compose_files=(-f "$compose")
# Images built before the repo rename (stan/personal-website -> stan/website) keep
# their old local name; retag so rollbacks to those SHAs still work.
legacy_image="git.stanrunge.dev/stan/personal-website:$sha"
if ! sudo docker image inspect "$image" >/dev/null 2>&1 &&
sudo docker image inspect "$legacy_image" >/dev/null 2>&1; then
sudo docker tag "$legacy_image" "$image"
fi
if ! sudo docker image inspect "$image" >/dev/null 2>&1; then
echo "Image $image is not on this host" >&2; exit 1
fi
# Book files (ebooks + covers) live on the host and are bind-mounted read-only at /books;
# the app streams them only to the owner via /trackers/books/files/<id> (no static path).
# Layout: $books_host_dir/library/<files.path> and $books_host_dir/covers/<works.cover_path>.
# compose.prod.yml is host-managed, so the mount goes in a generated override file.
# compose.prod.yml is host-managed, so the mount goes in a generated override file (~deploy).
books_host_dir=${BOOKS_HOST_DIR:-/home/stan/books}
case "$books_host_dir" in /*) ;; *) echo 'BOOKS_HOST_DIR must be absolute' >&2; exit 2 ;; esac
if [ -d "$books_host_dir/library" ]; then
sudo tee "$directory/compose.books.yml" >/dev/null <<YAML
# The deploy account can't traverse /home/stan (0750); check from a root container instead.
if sudo docker run --rm --entrypoint sh \
--mount "type=bind,source=$books_host_dir,target=/books,readonly" "$image" \
-c 'test -d /books/library' 2>/dev/null; then
# The deploy account may only sudo-tee $directory/.env, so the override lives in its home;
# the compose CLI runs as root and can read it from there.
books_override="$HOME/compose.books.yml"
rm -f "$books_override"
cat >"$books_override" <<YAML
services:
app:
environment:
@@ -37,20 +54,10 @@ services:
target: /books
read_only: true
YAML
compose_files+=(-f "$directory/compose.books.yml")
compose_files+=(-f "$books_override")
else
echo "No $books_host_dir/library on this host; book files will not be readable" >&2
fi
# Images built before the repo rename (stan/personal-website -> stan/website) keep
# their old local name; retag so rollbacks to those SHAs still work.
legacy_image="git.stanrunge.dev/stan/personal-website:$sha"
if ! sudo docker image inspect "$image" >/dev/null 2>&1 &&
sudo docker image inspect "$legacy_image" >/dev/null 2>&1; then
sudo docker tag "$legacy_image" "$image"
fi
if ! sudo docker image inspect "$image" >/dev/null 2>&1; then
echo "Image $image is not on this host" >&2; exit 1
fi
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
switched=0
rollback() {
@@ -103,6 +110,12 @@ run_database_script() {
if [ "$mode" = deploy ]; then
# Schema changes: Drizzle migrations from ./drizzle, as the schema owner.
run_database_script scripts/db-migrate.ts
# Refresh the generic table GUI's column registry (meta.columns) for tables that migrations
# registered in meta.tables. meta.sync() is SECURITY DEFINER and granted to the app role, not
# to the schema owner, so run it as DB_URL (personal_app).
echo "Running meta.sync()"
sudo docker run --rm --network backend --env-file "$environment" "$image" \
bun scripts/db-meta-sync.ts
run_database_script scripts/seed-catalog.ts
run_database_script scripts/seed-cod-catalog.ts
run_database_script scripts/seed-pokemon-research.ts

46
scripts/tasks-import.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* Import tasks, goals and areas into the private `tasks` schema (idempotent).
*
* bun scripts/tasks-import.ts data/tasks/daisy-import.json # insert what is missing
* bun scripts/tasks-import.ts --dry-run data/tasks/daisy-import.json # validate only
* bun scripts/tasks-import.ts --update data/tasks/daisy-import.json # also overwrite existing rows
*
* Dedupe: areas on slug, goals on title, tasks on area + title (case-insensitive).
* 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 { importSeed, seedFile } from '../src/lib/server/tasks';
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const update = args.includes('--update');
const files = args.filter((a) => !a.startsWith('--'));
if (files.length !== 1) {
console.error('usage: bun scripts/tasks-import.ts [--dry-run] [--update] <seed.json>');
process.exit(2);
}
const parsed = seedFile.safeParse(JSON.parse(await readFile(files[0], 'utf8')));
if (!parsed.success) {
for (const issue of parsed.error.issues.slice(0, 50))
console.error(`${issue.path.join('.')}: ${issue.message}`);
process.exit(1);
}
const seed = parsed.data;
console.log(
`Valid: ${seed.areas.length} areas, ${seed.goals.length} goals, ${seed.tasks.length} tasks`
);
if (dryRun) 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 r = await importSeed(drizzle({ client }), seed, { update });
console.log(
`areas: ${r.areas.inserted} inserted, ${r.areas.existing} existing; ` +
`goals: ${r.goals.inserted} inserted, ${r.goals.existing} existing, ${r.goals.updated} updated; ` +
`tasks: ${r.tasks.inserted} inserted, ${r.tasks.existing} existing, ${r.tasks.updated} updated`
);
} finally {
await client.end();
}

View File

@@ -0,0 +1,79 @@
/**
* Export every TickTick task reachable through the official CLI (Open API v1):
* open, won't-do and completed tasks of the inbox and every list, plus completed
* history month by month (windows are split further when they hit the API's
* 200-result cap). Needs `ticktick auth login` done once; never reads the token.
*
* bun scripts/ticktick-export.ts ~/.cache/ticktick-export/all.json
*
* Then: bun scripts/ticktick-import.ts ~/.cache/ticktick-export/all.json
*/
import { $ } from 'bun';
const out = process.argv[2];
if (!out) {
console.error('usage: bun scripts/ticktick-export.ts <out.json> [--since YYYY]');
process.exit(2);
}
const sinceArg = process.argv.indexOf('--since');
const since = sinceArg > 0 ? Number(process.argv[sinceArg + 1]) : 2015;
const CAP = 200;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Json = any;
async function tt(...args: string[]): Promise<Json> {
for (let attempt = 0; ; attempt++) {
const r = await $`ticktick ${args} --json`.quiet().nothrow();
if (r.exitCode === 0) return JSON.parse(r.stdout.toString() || '[]');
if (attempt >= 3)
throw new Error(
`ticktick ${args[0]} ${args[1]} failed: ${r.stderr.toString().slice(0, 300)}`
);
await Bun.sleep(2000 * (attempt + 1));
}
}
const fmt = (d: Date) => d.toISOString().replace(/\.\d{3}Z$/, '+0000');
const byId = new Map<string, Json>();
const add = (list: Json[]) => list.filter((t) => !byId.has(t.id) && byId.set(t.id, t)).length;
const projects: Json[] = await tt('project', 'list');
// The inbox is not a project; its id shows up on tasks (inbox<userId>).
const probe: Json[] = await tt('task', 'filter', '--status=0,2,-1');
const inboxId = probe.map((t) => t.projectId).find((p: string) => p?.startsWith('inbox'));
add(probe);
const ids = [...(inboxId ? [inboxId] : []), ...projects.map((p) => p.id)];
let capped = 0;
for (const status of ['0', '-1', '2'])
for (const p of ids) {
const r = await tt('task', 'filter', '--projects', p, `--status=${status}`);
if (r.length >= CAP) capped++;
add(r);
}
for (const p of projects) add((await tt('project', 'data', p.id)).tasks ?? []);
async function completed(start: Date, end: Date): Promise<void> {
const r = await tt('task', 'completed', '--start-date', fmt(start), '--end-date', fmt(end));
if (r.length >= CAP && end.getTime() - start.getTime() > 3_600_000) {
const mid = new Date(Math.floor((start.getTime() + end.getTime()) / 2000) * 1000);
await completed(start, mid);
await completed(new Date(mid.getTime() + 1000), end);
return;
}
add(r);
}
const now = new Date();
for (let y = since; y <= now.getUTCFullYear(); y++)
for (let m = 0; m < 12; m++) {
const start = new Date(Date.UTC(y, m, 1));
if (start > now) break;
await completed(start, new Date(Date.UTC(y, m + 1, 1) - 1000));
}
const tasks = [...byId.values()];
await Bun.write(out, JSON.stringify({ exportedAt: now.toISOString(), inboxId, projects, tasks }));
const count = (s: number) => tasks.filter((t) => t.status === s).length;
console.log(
`${tasks.length} tasks (${count(0)} open, ${count(2)} completed, ${count(-1)} won't do) from ` +
`${projects.length} lists + inbox` +
(capped ? `; ${capped} status/list queries hit the ${CAP} cap (older ones may be missing)` : '')
);

View File

@@ -0,0 +1,65 @@
/**
* Sync TickTick into the private `tasks` schema. Idempotent; rerun after a new export.
*
* bun scripts/ticktick-import.ts [--dry-run] [--csv backup.csv] ~/.cache/ticktick-export/all.json
*
* all.json comes from scripts/ticktick-export.ts (Open API). --csv adds the tasks from a
* TickTick backup (Settings → Backup & Import) that the API no longer returns (closed lists,
* old won't-do tasks); rows the API also has are matched and skipped (see mergeCsv).
* Links listed in data/tasks/ticktick-merges.json (plus exact title matches, see
* syncTickTick) attach a TickTick task to an existing task instead of duplicating it.
* 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 {
mergeCsv,
parseTickTickCsv,
syncTickTick,
type TtExport,
type TtMerge
} from '../src/lib/server/ticktick';
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const csvAt = args.indexOf('--csv');
const csvPath = csvAt >= 0 ? args[csvAt + 1] : undefined;
const files = args.filter((a, i) => !a.startsWith('--') && (csvAt < 0 || i !== csvAt + 1));
if (files.length !== 1 || (csvAt >= 0 && !csvPath)) {
console.error(
'usage: bun scripts/ticktick-import.ts [--dry-run] [--csv backup.csv] <export.json>'
);
process.exit(2);
}
let data = JSON.parse(await readFile(files[0], 'utf8')) as TtExport;
if (csvPath) {
const merged = mergeCsv(data, parseTickTickCsv(await readFile(csvPath, 'utf8')));
data = merged.data;
console.log(JSON.stringify({ csv: merged.report }));
}
const merges = JSON.parse(
await readFile(new URL('../data/tasks/ticktick-merges.json', import.meta.url), 'utf8')
) as TtMerge[];
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
class DryRun extends Error {}
try {
const db = drizzle({ client });
try {
const report = await db.transaction(async (tx) => {
const r = await syncTickTick(tx, data, { merges });
if (dryRun) {
console.log(JSON.stringify(r, null, 2));
throw new DryRun();
}
return r;
});
console.log(JSON.stringify(report, null, 2));
} catch (e) {
if (!(e instanceof DryRun)) throw e;
console.log('(dry run, rolled back)');
}
} finally {
await client.end();
}

View File

@@ -1,5 +1,5 @@
// Validation and maths for the reader's progress API (pure, unit tested).
import { z } from 'zod';
import * as z from 'zod';
export const progressPayload = z.object({
fileId: z.number().int().positive().nullish(),

View File

@@ -1,7 +1,8 @@
<script lang="ts">
// Reflowable books (EPUB, MOBI/AZW3, FB2, CBZ) via the vendored foliate-js.
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import { highlightCss, type Highlight, type HighlightColor } from '#lib/books/shared.js';
import { wheelZoom, type ViewLayout } from './layout.js';
import {
fetchWithProgress,
themeColors,
@@ -40,7 +41,9 @@
startPercent,
highlights,
settings,
layout,
theme,
onzoom,
onready,
onrelocate,
onselection,
@@ -53,18 +56,25 @@
let host = $state<FoliateView>();
let view = $state<FoliateView>();
let highlightDraw: DrawFn | undefined;
// Zoom re-lays out the whole section, so follow fast changes (Ctrl+wheel, pinch) lazily.
let zoom = $state(untrack(() => layout.zoom));
$effect(() => {
const z = layout.zoom;
const t = setTimeout(() => (zoom = z), 120);
return () => clearTimeout(t);
});
const fonts = {
serif: 'Charter, "Iowan Old Style", Georgia, "Palatino Linotype", serif',
sans: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'
};
function css(s: ReaderSettings, t: ReaderTheme) {
function css(s: ReaderSettings, t: ReaderTheme, z: number) {
const c = themeColors[t];
const family = s.font === 'publisher' ? null : fonts[s.font];
return `
@namespace epub "http://www.idpf.org/2007/ops";
html { color-scheme: ${t === 'dark' ? 'dark' : 'light'}; background: ${c.bg} !important; color: ${c.fg} !important; font-size: ${s.fontSize}% !important; }
html { color-scheme: ${t === 'dark' ? 'dark' : 'light'}; background: ${c.bg} !important; color: ${c.fg} !important; font-size: ${Math.round(z * 100)}% !important; }
body { background: transparent !important; color: inherit !important; ${family ? `font-family: ${family} !important;` : ''} }
${family ? 'body :not(pre, code, kbd, samp, tt, pre *, code *) { font-family: inherit !important; }' : ''}
p, li, blockquote, dd, dt { line-height: ${s.lineHeight} !important; }
@@ -76,19 +86,26 @@ aside[epub|type~="footnote"], aside[epub|type~="endnote"] { display: none; }
::selection { background: rgba(250, 204, 21, 0.45); }`;
}
function apply(v: FoliateView) {
// Fixed-layout books (comics, FXL EPUB) use foliate's own page fitting and spreads.
const isFixed = (v: FoliateView) => v.renderer?.tagName === 'FOLIATE-FXL';
function apply(v: FoliateView, l: Pick<ViewLayout, 'mode' | 'spread'>, z: number) {
const r = v.renderer;
r.setAttribute('flow', settings.flow);
r.setAttribute('margin', '48px');
r.setAttribute('gap', '6%');
r.setAttribute('max-inline-size', '720px');
r.setAttribute('max-column-count', '2');
r.setStyles(css(settings, theme));
if (!isFixed(v)) {
r.setAttribute('flow', l.mode === 'scroll' ? 'scrolled' : 'paginated');
r.setAttribute('margin', '48px');
r.setAttribute('gap', '6%');
// Wider columns as the text grows, so a zoomed spread falls back to one column
// instead of squeezing a few words per line.
r.setAttribute('max-inline-size', `${Math.round(720 * Math.max(1, z))}px`);
r.setAttribute('max-column-count', String(l.spread));
}
r.setStyles?.(css(settings, theme, z));
v.style.background = themeColors[theme].bg;
}
$effect(() => {
if (view) apply(view);
if (view) apply(view, { mode: layout.mode, spread: layout.spread }, zoom);
});
const color = (h: Highlight) =>
@@ -158,6 +175,16 @@ aside[epub|type~="footnote"], aside[epub|type~="endnote"] { display: none; }
el.addEventListener('load', (e) => {
const { doc, index } = (e as CustomEvent).detail as { doc: Document; index: number };
doc.addEventListener('keydown', onkey);
// Ctrl+wheel / trackpad pinch inside the book text zooms instead of the page.
doc.addEventListener(
'wheel',
(we) => {
if (!we.ctrlKey && !we.metaKey) return;
we.preventDefault();
onzoom(wheelZoom(layout.zoom, we.deltaY, 'foliate'));
},
{ passive: false }
);
let timer: ReturnType<typeof setTimeout>;
doc.addEventListener('selectionchange', () => {
clearTimeout(timer);
@@ -192,11 +219,11 @@ aside[epub|type~="footnote"], aside[epub|type~="endnote"] { display: none; }
await el.open(new File([blob], name, { type: blob.type }));
if (disposed) return;
apply(el);
apply(el, layout, layout.zoom);
view = el;
const lastLocation = start ?? (startPercent > 0 ? { fraction: startPercent / 100 } : null);
await el.init({ lastLocation, showTextStart: !lastLocation });
onready(toToc(el.book.toc));
onready(toToc(el.book.toc), { fixedLayout: isFixed(el) });
} catch (e) {
if (!disposed && !(e instanceof DOMException && e.name === 'AbortError'))
onerror(e instanceof Error ? e.message : 'Could not open this book');

View File

@@ -1,7 +1,8 @@
<script lang="ts">
// PDFs via pdf.js: continuous scroll, pages rendered lazily near the viewport, a text layer
// for selection, and highlights stored as page-relative rectangles.
import { onMount } from 'svelte';
// PDFs via pdf.js: continuous scroll or one page/spread at a time, 1 or 2 pages per row,
// pages rendered lazily near the viewport, a text layer for selection, and highlights stored
// as page-relative rectangles.
import { onMount, tick, untrack } from 'svelte';
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
import {
@@ -12,6 +13,8 @@
type HighlightColor,
type Rect
} from '#lib/books/shared.js';
import { clampZoom, pdfRows, rowIndexOf, type Spread } from './layout.js';
import { closePdf } from './pdf.js';
import { type EngineProps, type TocItem } from './types.js';
let {
@@ -19,8 +22,9 @@
start,
startPercent,
highlights,
settings,
layout,
theme,
onzoom,
onready,
onrelocate,
onselection,
@@ -30,25 +34,37 @@
}: EngineProps = $props();
type PageInfo = { num: number; width: number; height: number }; // at scale 1
type Live = { task?: RenderTask; el: HTMLElement; layer: HTMLElement };
type Live = { task?: RenderTask; el: HTMLElement; layer: HTMLElement; scale: number };
const GAP = 12;
let scroller: HTMLDivElement;
let pdf: PDFDocumentProxy | undefined;
let pdfjs: typeof import('pdfjs-dist') | undefined;
let pages = $state<PageInfo[]>([]);
let width = $state(0);
let height = $state(0);
let current = 1;
let currentRow = $state(0); // paged mode: the row (page or spread) on screen
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
const live = new Map<number, Live>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
const pageEls = new Map<number, HTMLElement>();
let observer: IntersectionObserver | undefined;
const paged = $derived(layout.mode === 'paged');
// Two pages side by side only when there's room for them.
const spread = $derived<Spread>(layout.spread === 2 && width >= 640 ? 2 : 1);
const rows = $derived(pdfRows(pages.length, spread));
const shownRows = $derived(paged ? rows.slice(currentRow, currentRow + 1) : rows);
// Zoom 1 = fit the row's width (scroll) or the whole page/spread (paged).
const scale = $derived.by(() => {
const first = pages[0];
if (!first || !width) return 1;
const fit = Math.min(width - 24, 920) / first.width;
return Math.max(0.3, fit * settings.pdfZoom);
const avail = width - 2 * GAP - (spread - 1) * GAP;
let fit = Math.min(avail, 920 * spread) / (first.width * spread);
if (paged && height > 2 * GAP) fit = Math.min(fit, (height - 2 * GAP) / first.height);
return Math.max(0.2, fit * layout.zoom);
});
const byPage = $derived.by(() => {
@@ -70,11 +86,12 @@
const el = pageEls.get(num);
if (!pdf || !pdfjs || !el || live.has(num)) return;
const layer = el.querySelector<HTMLElement>('.layer')!;
const entry: Live = { el, layer };
const entry: Live = { el, layer, scale };
live.set(num, entry);
const page: PDFPageProxy = await pdf.getPage(num);
if (live.get(num) !== entry) return;
const s = scale;
entry.scale = s;
const viewport = page.getViewport({ scale: s });
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const canvas = document.createElement('canvas');
@@ -83,8 +100,6 @@
const text = document.createElement('div');
text.className = 'textLayer';
layer.replaceChildren(canvas, text);
el.style.setProperty('--scale-factor', String(s));
el.style.setProperty('--total-scale-factor', String(s));
entry.task = page.render({
canvas,
viewport,
@@ -123,30 +138,53 @@
};
}
// Re-render everything when the zoom or the width changes, keeping the reading position.
let lastScale = 0;
$effect(() => {
const s = scale;
// The first scale with pages loaded is the baseline; the initial jump to the saved
// position happens in onMount and must not be undone by a "keep position" here.
if (!pages.length || !width) return;
if (!lastScale) {
lastScale = s;
return;
}
if (Math.abs(s - lastScale) < 0.001) return;
lastScale = s;
const keep = position();
for (const num of [...live.keys()]) unrender(num);
requestAnimationFrame(() => {
scrollToPosition(keep.page, keep.fraction);
for (const [num, node] of pageEls) {
const r = node.getBoundingClientRect();
const v = scroller.getBoundingClientRect();
if (r.bottom > v.top - v.height && r.top < v.bottom + v.height) void render(num);
}
// When the zoom, width, spread or mode changes: keep the reading position, resize the page
// boxes right away (old canvases stretch meanwhile) and re-render sharp once it settles.
const layoutKey = $derived(`${layout.mode}|${spread}|${scale.toFixed(4)}`);
let lastKey = '';
let keep: { page: number; fraction: number } | null = null;
$effect.pre(() => {
const key = layoutKey;
untrack(() => {
// The DOM still has the old layout here, so this reads the position the reader sees.
if (lastKey && key !== lastKey && pages.length) keep ??= position();
});
});
$effect(() => {
const key = layoutKey;
untrack(() => {
if (!pages.length || !width) return void (keep = null);
// The first layout with pages is the baseline; the initial jump to the saved position
// happens in onMount and must not be undone by a "keep position" here.
if (!lastKey || key === lastKey) return void ((lastKey = key), (keep = null));
lastKey = key;
const k = keep;
keep = null;
if (k) {
if (paged) currentRow = rowIndexOf(rows, k.page);
void tick().then(() => {
place(k.page, k.fraction);
onscroll();
});
}
scheduleRerender();
});
});
let rerenderTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleRerender() {
clearTimeout(rerenderTimer);
rerenderTimer = setTimeout(() => {
for (const [num, entry] of [...live])
if (Math.abs(entry.scale - scale) > 0.001) unrender(num);
if (!scroller) return;
const v = scroller.getBoundingClientRect();
for (const [num, node] of pageEls) {
const r = node.getBoundingClientRect();
if (r.bottom > v.top - v.height && r.top < v.bottom + v.height) void render(num);
}
}, 160);
}
function position() {
if (!scroller) return { page: 1, fraction: 0 };
@@ -165,23 +203,54 @@
return { page: pages.length, fraction: 1 };
}
function scrollToPosition(num: number, fraction = 0) {
const node = pageEls.get(Math.max(1, Math.min(pages.length, num)));
if (!node) return;
/** Scroll so `num` (at `fraction` down the page) is in view; it must be in the DOM. */
function place(num: number, fraction = 0) {
const node = pageEls.get(num);
if (!node || !scroller) return;
scroller.scrollTop =
node.offsetTop + node.offsetHeight * fraction - (fraction ? scroller.clientHeight * 0.3 : 12);
node.offsetTop +
node.offsetHeight * fraction -
(fraction ? scroller.clientHeight * 0.3 : GAP);
}
function scrollToPosition(num: number, fraction = 0) {
if (!pages.length) return;
num = Math.max(1, Math.min(pages.length, num));
if (paged) {
const r = rowIndexOf(rows, num);
if (r !== currentRow) {
currentRow = r;
void tick().then(() => {
place(num, fraction);
onscroll();
});
return;
}
place(num, fraction);
onscroll(); // the scroll position may not change between rows
return;
}
place(num, fraction);
}
function showRow(i: number) {
const row = rows[Math.max(0, Math.min(rows.length - 1, i))];
if (row) scrollToPosition(row[0]);
}
let frame = 0;
function onscroll() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
if (!pages.length) return;
const { page: num, fraction } = position();
current = num;
const row = rows[rowIndexOf(rows, num)];
onrelocate({
percent: Math.min(100, ((num - 1 + fraction) / pages.length) * 100),
locator: pdfLocator(num),
page: num,
pageEnd: row && row.length > 1 ? row[row.length - 1] : null,
pageCount: pages.length,
label: labelFor(num)
});
@@ -207,12 +276,18 @@
const p = (percent / 100) * pages.length;
scrollToPosition(Math.floor(p) + 1, p % 1 || 0);
}
// Next/prev move a whole row: one page, or two in spread mode.
export function next() {
scrollToPosition(current + 1);
if (paged) return showRow(currentRow + 1);
showRow(rowIndexOf(rows, current) + 1);
}
export function prev() {
const { fraction } = position();
scrollToPosition(fraction > 0.1 ? current : current - 1);
if (paged) return showRow(currentRow - 1);
// Scrolled into the current row: back to its top first, else the previous row.
const r = rowIndexOf(rows, current);
const node = pageEls.get(rows[r]?.[0] ?? 1);
const into = node && scroller ? scroller.scrollTop - (node.offsetTop - GAP) : 0;
showRow(into > 24 ? r : r - 1);
}
export function addHighlight() {
// Highlights are derived from props; nothing to draw imperatively.
@@ -296,9 +371,51 @@
onMount(() => {
let disposed = false;
const resize = new ResizeObserver(() => (width = scroller.clientWidth));
let loadingTask: ReturnType<typeof import('pdfjs-dist').getDocument> | undefined;
const measure = () => {
width = scroller.clientWidth;
height = scroller.clientHeight;
};
const resize = new ResizeObserver(measure);
resize.observe(scroller);
width = scroller.clientWidth;
measure();
// Ctrl+wheel / trackpad pinch is handled by the shell; here: wheel turns pages in paged
// mode when the page fits, and two-finger pinch zooms on touch screens.
let wheelAcc = 0;
let wheelLock = 0;
const onWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey || !paged) return;
if (scroller.scrollHeight > scroller.clientHeight + 1) return;
e.preventDefault();
if (Date.now() < wheelLock) return;
wheelAcc += e.deltaY;
if (Math.abs(wheelAcc) < 60) return;
if (wheelAcc > 0) next();
else prev();
wheelAcc = 0;
wheelLock = Date.now() + 350;
};
let pinch: { d: number; z: number } | null = null;
const dist = (t: TouchList) =>
Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY) || 1;
const onTouchStart = (e: TouchEvent) => {
pinch = e.touches.length === 2 ? { d: dist(e.touches), z: layout.zoom } : null;
};
const onTouchMove = (e: TouchEvent) => {
if (!pinch || e.touches.length !== 2) return;
e.preventDefault();
const z = clampZoom((pinch.z * dist(e.touches)) / pinch.d, 'pdf');
if (Math.abs(z - layout.zoom) > 0.01) onzoom(z);
};
const onTouchEnd = (e: TouchEvent) => {
if (e.touches.length < 2) pinch = null;
};
scroller.addEventListener('wheel', onWheel, { passive: false });
scroller.addEventListener('touchstart', onTouchStart, { passive: true });
scroller.addEventListener('touchmove', onTouchMove, { passive: false });
scroller.addEventListener('touchend', onTouchEnd);
scroller.addEventListener('touchcancel', onTouchEnd);
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
@@ -335,15 +452,15 @@
pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
onloading(0);
const task = pdfjs.getDocument({
const task = (loadingTask = pdfjs.getDocument({
url,
withCredentials: true,
rangeChunkSize: 1 << 20
});
}));
task.onProgress = ({ loaded, total }: { loaded: number; total: number }) =>
onloading(total ? Math.min(1, loaded / total) : null);
const doc = await task.promise;
if (disposed) return void doc.destroy();
if (disposed) return void closePdf(task);
pdf = doc;
onloading(null);
// Use the first page's size for every page up front; real sizes fill in quickly.
@@ -375,10 +492,17 @@
return () => {
disposed = true;
document.removeEventListener('selectionchange', onSelectionChange);
scroller.removeEventListener('wheel', onWheel);
scroller.removeEventListener('touchstart', onTouchStart);
scroller.removeEventListener('touchmove', onTouchMove);
scroller.removeEventListener('touchend', onTouchEnd);
scroller.removeEventListener('touchcancel', onTouchEnd);
clearTimeout(rerenderTimer);
resize.disconnect();
observer?.disconnect();
for (const num of [...live.keys()]) unrender(num);
void pdf?.destroy();
// pdf.js 6 has no PDFDocumentProxy.destroy(); calling it threw on every unmount.
void closePdf(loadingTask);
};
});
</script>
@@ -389,31 +513,38 @@
bind:this={scroller}
{onscroll}
>
<div class="pages">
{#each pages as p (p.num)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="pdf-page"
data-page={p.num}
style:width={`${Math.floor(p.width * scale)}px`}
style:height={`${Math.floor(p.height * scale)}px`}
use:page={p.num}
onclick={(e) => hitHighlight(e, p.num)}
>
<div class="layer"></div>
{#each byPage.get(p.num) ?? [] as { h, rects } (h.id)}
{#each rects as r, i (i)}
<span
class="hl"
style:left={`${r.x * 100}%`}
style:top={`${r.y * 100}%`}
style:width={`${r.w * 100}%`}
style:height={`${r.h * 100}%`}
style:background={color(h)}
></span>
{/each}
<div class={['pages', paged && 'paged', spread === 2 && 'spread']}>
{#each shownRows as row (row[0])}
<div class="row">
{#each row as num (num)}
{@const p = pages[num - 1]}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="pdf-page"
data-page={p.num}
style:width={`${Math.floor(p.width * scale)}px`}
style:height={`${Math.floor(p.height * scale)}px`}
style:--scale-factor={scale}
style:--total-scale-factor={scale}
use:page={p.num}
onclick={(e) => hitHighlight(e, p.num)}
>
<div class="layer"></div>
{#each byPage.get(p.num) ?? [] as { h, rects } (h.id)}
{#each rects as r, i (i)}
<span
class="hl"
style:left={`${r.x * 100}%`}
style:top={`${r.y * 100}%`}
style:width={`${r.w * 100}%`}
style:height={`${r.h * 100}%`}
style:background={color(h)}
></span>
{/each}
{/each}
<span class="num">{p.num}</span>
</div>
{/each}
<span class="num">{p.num}</span>
</div>
{/each}
</div>
@@ -434,6 +565,21 @@
padding: 12px 12px 96px;
min-width: fit-content;
}
.pages.paged {
min-height: 100%;
justify-content: center;
padding: 12px;
}
/* rows are not positioned, so page offsetTop stays relative to the scroller */
.row {
display: flex;
align-items: flex-start;
justify-content: center;
gap: 12px;
}
.spread .num {
display: none;
}
.pdf-page {
position: relative;
background: white;

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import {
clampZoom,
defaultLayouts,
loadLayout,
normalizeLayout,
pdfRows,
rowIndexOf,
saveLayout,
stepZoom,
wheelZoom
} from './layout.js';
function memoryStore(init: Record<string, string> = {}) {
const data = new Map(Object.entries(init));
return {
data,
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => void data.set(k, v)
};
}
describe('zoom helpers', () => {
it('clamps per format and repairs junk', () => {
expect(clampZoom(10, 'pdf')).toBe(4);
expect(clampZoom(0.1, 'pdf')).toBe(0.5);
expect(clampZoom(10, 'foliate')).toBe(2.5);
expect(clampZoom(Number.NaN, 'pdf')).toBe(1);
expect(clampZoom(-2, 'foliate')).toBe(1);
});
it('steps through presets from any value', () => {
expect(stepZoom(1, 1, 'pdf')).toBe(1.1);
expect(stepZoom(1, -1, 'pdf')).toBe(0.9);
expect(stepZoom(1.03, 1, 'pdf')).toBe(1.1);
expect(stepZoom(1.03, -1, 'pdf')).toBe(1);
expect(stepZoom(4, 1, 'pdf')).toBe(4);
expect(stepZoom(0.5, -1, 'pdf')).toBe(0.5);
expect(stepZoom(2.5, 1, 'foliate')).toBe(2.5);
expect(stepZoom(0.6, -1, 'foliate')).toBe(0.6);
});
it('wheel zooms smoothly in the right direction', () => {
expect(wheelZoom(1, -100, 'pdf')).toBeGreaterThan(1);
expect(wheelZoom(1, 100, 'pdf')).toBeLessThan(1);
// small trackpad deltas still move
expect(wheelZoom(1, -2, 'pdf')).not.toBe(1);
expect(wheelZoom(4, -100, 'pdf')).toBe(4);
});
});
describe('pdf rows', () => {
it('single pages', () => {
expect(pdfRows(3, 1)).toEqual([[1], [2], [3]]);
expect(pdfRows(0, 2)).toEqual([]);
});
it('spreads keep the cover alone', () => {
expect(pdfRows(1, 2)).toEqual([[1]]);
expect(pdfRows(4, 2)).toEqual([[1], [2, 3], [4]]);
expect(pdfRows(5, 2)).toEqual([[1], [2, 3], [4, 5]]);
});
it('finds the row of a page', () => {
const rows = pdfRows(5, 2);
expect(rowIndexOf(rows, 1)).toBe(0);
expect(rowIndexOf(rows, 3)).toBe(1);
expect(rowIndexOf(rows, 4)).toBe(2);
expect(rowIndexOf(rows, 99)).toBe(2);
expect(rowIndexOf(rows, -1)).toBe(0);
expect(rowIndexOf([], 3)).toBe(0);
});
});
describe('layout persistence', () => {
it('defaults per format', () => {
const s = memoryStore();
expect(loadLayout(s, 'pdf', 1)).toEqual(defaultLayouts.pdf);
expect(loadLayout(s, 'foliate', 1)).toEqual(defaultLayouts.foliate);
});
it('remembers layout per format and zoom per book', () => {
const s = memoryStore();
saveLayout(s, 'pdf', 7, { mode: 'paged', spread: 2, zoom: 1.5 });
expect(loadLayout(s, 'pdf', 7)).toEqual({ mode: 'paged', spread: 2, zoom: 1.5 });
// another PDF inherits the format's last layout
expect(loadLayout(s, 'pdf', 8)).toEqual({ mode: 'paged', spread: 2, zoom: 1.5 });
saveLayout(s, 'pdf', 8, { mode: 'paged', spread: 2, zoom: 0.75 });
expect(loadLayout(s, 'pdf', 7).zoom).toBe(1.5);
// formats are independent
expect(loadLayout(s, 'foliate', 7)).toEqual(defaultLayouts.foliate);
});
it('migrates the old settings once', () => {
const s = memoryStore({
'books-reader-settings': JSON.stringify({ flow: 'scrolled', fontSize: 120, pdfZoom: 1.3 })
});
expect(loadLayout(s, 'foliate', 1)).toEqual({ mode: 'scroll', spread: 2, zoom: 1.2 });
expect(loadLayout(s, 'pdf', 1)).toEqual({ mode: 'scroll', spread: 1, zoom: 1.3 });
});
it('survives broken storage', () => {
const s = memoryStore({ 'books-reader-layout:pdf': '{nope', 'books-reader-zoom:pdf:1': '"x"' });
expect(loadLayout(s, 'pdf', 1)).toEqual(defaultLayouts.pdf);
expect(normalizeLayout({ mode: 'x', spread: 3, zoom: 99 }, 'foliate')).toEqual({
mode: 'paged',
spread: 2,
zoom: 2.5
});
});
});

View File

@@ -0,0 +1,124 @@
// View layout for the reader: zoom, 1/2-page spreads and scroll vs paged reading.
// Pure helpers (unit tested) plus localStorage persistence: mode/spread/zoom are remembered
// per format (pdf vs reflowable), and the zoom is also remembered per book.
export type EngineKind = 'pdf' | 'foliate';
export type ViewMode = 'scroll' | 'paged';
export type Spread = 1 | 2;
export type ViewLayout = { mode: ViewMode; spread: Spread; zoom: number };
export const defaultLayouts: Record<EngineKind, ViewLayout> = {
pdf: { mode: 'scroll', spread: 1, zoom: 1 },
foliate: { mode: 'paged', spread: 2, zoom: 1 }
};
/** Zoom limits: PDF zoom scales the page render (1 = fit), foliate zoom scales the text. */
export const zoomRange: Record<EngineKind, [min: number, max: number]> = {
pdf: [0.5, 4],
foliate: [0.6, 2.5]
};
const ZOOM_STEPS = [0.5, 0.6, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4];
export function clampZoom(zoom: number, kind: EngineKind): number {
const [min, max] = zoomRange[kind];
const z = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000;
}
/** Next preset zoom level up (dir 1) or down (dir -1) from wherever the zoom is now. */
export function stepZoom(zoom: number, dir: 1 | -1, kind: EngineKind): number {
const [min, max] = zoomRange[kind];
const z = clampZoom(zoom, kind);
const next =
dir > 0
? (ZOOM_STEPS.find((s) => s > z + 0.001) ?? max)
: (ZOOM_STEPS.findLast((s) => s < z - 0.001) ?? min);
return clampZoom(next, kind);
}
/** Smooth zoom for Ctrl+wheel and trackpad pinch (which browsers report as Ctrl+wheel). */
export function wheelZoom(zoom: number, deltaY: number, kind: EngineKind): number {
// A mouse notch (~100) is one ~10% step; pinch deltas are small and smooth.
const d = Math.max(-50, Math.min(50, deltaY));
return clampZoom(zoom * Math.exp(-d * 0.002), kind);
}
/** PDF rows: one page per row, or book spreads with the cover on its own ([1], [2,3], [4,5]…). */
export function pdfRows(count: number, spread: Spread): number[][] {
const rows: number[][] = [];
if (count <= 0) return rows;
if (spread === 1) {
for (let n = 1; n <= count; n++) rows.push([n]);
return rows;
}
rows.push([1]);
for (let n = 2; n <= count; n += 2) rows.push(n + 1 <= count ? [n, n + 1] : [n]);
return rows;
}
/** Index of the row containing `page` (clamped into range). */
export function rowIndexOf(rows: number[][], page: number): number {
if (!rows.length) return 0;
const idx = rows.findIndex((r) => r.includes(page));
if (idx >= 0) return idx;
return page < rows[0][0] ? 0 : rows.length - 1;
}
type Store = Pick<Storage, 'getItem' | 'setItem'>;
const formatKey = (kind: EngineKind) => `books-reader-layout:${kind}`;
const bookKey = (kind: EngineKind, bookId: number) => `books-reader-zoom:${kind}:${bookId}`;
const LEGACY_KEY = 'books-reader-settings';
function read(store: Store, key: string): unknown {
try {
return JSON.parse(store.getItem(key) ?? 'null');
} catch {
return null;
}
}
/** Sanitises anything (old/broken storage) into a valid layout. */
export function normalizeLayout(raw: unknown, kind: EngineKind): ViewLayout {
const base = defaultLayouts[kind];
const o = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
return {
mode: o.mode === 'scroll' || o.mode === 'paged' ? o.mode : base.mode,
spread: o.spread === 1 || o.spread === 2 ? o.spread : base.spread,
zoom: typeof o.zoom === 'number' ? clampZoom(o.zoom, kind) : base.zoom
};
}
export function loadLayout(store: Store, kind: EngineKind, bookId: number): ViewLayout {
let saved = read(store, formatKey(kind));
if (!saved) {
// Carry over the pre-layout settings (flow, fontSize, pdfZoom) once.
const legacy = read(store, LEGACY_KEY) as Record<string, unknown> | null;
if (legacy && typeof legacy === 'object') {
saved =
kind === 'pdf'
? { zoom: legacy.pdfZoom }
: {
mode:
legacy.flow === 'scrolled'
? 'scroll'
: legacy.flow === 'paginated'
? 'paged'
: null,
zoom: typeof legacy.fontSize === 'number' ? legacy.fontSize / 100 : null
};
}
}
const layout = normalizeLayout(saved, kind);
const bookZoom = read(store, bookKey(kind, bookId));
if (typeof bookZoom === 'number') layout.zoom = clampZoom(bookZoom, kind);
return layout;
}
export function saveLayout(store: Store, kind: EngineKind, bookId: number, layout: ViewLayout) {
try {
store.setItem(formatKey(kind), JSON.stringify(layout));
store.setItem(bookKey(kind, bookId), JSON.stringify(layout.zoom));
} catch {
// storage full or disabled: the layout still applies for this visit
}
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { closePdf } from './pdf.js';
// A tiny one-page PDF (pdf.js tolerates the missing xref table).
const PDF =
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n' +
'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n' +
'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 300]>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF';
const open = async () => {
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
return pdfjs.getDocument({ data: new TextEncoder().encode(PDF) });
};
describe('closePdf', () => {
it('the document itself has no destroy() in pdf.js 6 (the old teardown threw)', async () => {
const task = await open();
const doc = await task.promise;
expect(doc.numPages).toBe(1);
expect((doc as unknown as { destroy?: unknown }).destroy).toBeUndefined();
await closePdf(task);
});
it('destroys a loaded document via its loading task, idempotently', async () => {
const task = await open();
await task.promise;
await closePdf(task);
expect(task.destroyed).toBe(true);
await expect(closePdf(task)).resolves.toBeUndefined();
});
it('can close while still loading, or with nothing open', async () => {
const task = await open();
task.promise.catch(() => {}); // rejected by the destroy
await closePdf(task);
expect(task.destroyed).toBe(true);
await expect(closePdf(undefined)).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,18 @@
// pdf.js lifecycle helpers for the reader.
type LoadingTask = { destroyed: boolean; destroy(): Promise<void> };
/**
* Closes a PDF opened with pdfjs.getDocument(). pdf.js 6 removed PDFDocumentProxy.destroy();
* the loading task owns the document, its network requests and the worker, so tearing it
* down works both mid-load and after. Never throws: it runs while the reader unmounts
* (e.g. the back button), where an exception would break the navigation.
*/
export async function closePdf(task: LoadingTask | null | undefined): Promise<void> {
if (!task || task.destroyed) return;
try {
await task.destroy();
} catch {
// already torn down
}
}

View File

@@ -1,5 +1,6 @@
// Contract between the reader shell (the /read page) and the EPUB/PDF engines.
import type { Highlight } from '#lib/books/shared.js';
import type { ViewLayout } from './layout.js';
export type TocItem = { label: string; target: string | number; children: TocItem[] };
@@ -7,6 +8,7 @@ export type Relocation = {
percent: number; // 0-100
locator: string; // EPUB CFI or `page:N`
page: number | null; // PDF page (1-based), or null for reflowable books
pageEnd?: number | null; // last page shown when a PDF spread shows two
pageCount: number | null;
label: string | null; // current chapter
};
@@ -16,19 +18,13 @@ export type TextSelection = { text: string; locator: string; page: number | null
export type ReaderTheme = 'light' | 'sepia' | 'dark';
export type ReaderSettings = {
theme: 'auto' | ReaderTheme;
fontSize: number; // percent
lineHeight: number;
font: 'publisher' | 'serif' | 'sans';
flow: 'paginated' | 'scrolled';
pdfZoom: number; // 1 = fit width
};
export const defaultSettings: ReaderSettings = {
theme: 'auto',
fontSize: 100,
lineHeight: 1.5,
font: 'publisher',
flow: 'paginated',
pdfZoom: 1
font: 'publisher'
};
export const themeColors: Record<ReaderTheme, { bg: string; fg: string; link: string }> = {
@@ -55,8 +51,12 @@ export type EngineProps = {
startPercent: number;
highlights: Highlight[];
settings: ReaderSettings;
/** Zoom, 1/2-page spread and scroll vs paged (see layout.ts). */
layout: ViewLayout;
theme: ReaderTheme;
onready: (toc: TocItem[]) => void;
/** Ctrl+wheel / pinch inside the engine asks the shell for a new zoom. */
onzoom: (zoom: number) => void;
onready: (toc: TocItem[], info?: { fixedLayout?: boolean }) => void;
onrelocate: (r: Relocation) => void;
onselection: (s: TextSelection | null) => void;
onhighlightclick: (id: number) => void;

View File

@@ -33,7 +33,7 @@
'derby lens': ChartNoAxesCombined,
'world opinions': Globe2,
'language learner': Languages,
'money tracker': Wallet,
money: Wallet,
'cs prices': CandlestickChart,
'house tracker': House,
lists: ListChecks,

View File

@@ -0,0 +1,155 @@
<script lang="ts">
import type { Col } from './data-format.js';
let {
column,
value = $bindable(null),
lookupUrl,
autofocus = false,
compact = false,
onsave,
oncancel
}: {
column: Col;
value: string | null;
lookupUrl?: string;
autofocus?: boolean;
compact?: boolean;
onsave?: (move?: 'next' | 'prev') => void;
oncancel?: () => void;
} = $props();
const kind = $derived(column.kind);
const long = $derived(
kind === 'json' ||
column.uiType === 'longtext' ||
(kind === 'text' && (value?.length ?? 0) > 80)
);
const listId = `fk-${Math.random().toString(36).slice(2)}`;
let options = $state.raw<{ value: string; label: string }[]>([]);
let timer: ReturnType<typeof setTimeout> | undefined;
async function lookup(q: string) {
if (!column.fk || !lookupUrl) return;
const url = `${lookupUrl}?column=${encodeURIComponent(column.name)}&q=${encodeURIComponent(q)}`;
const res = await fetch(url).catch(() => null);
if (res?.ok) options = (await res.json()).options;
}
function queueLookup(q: string) {
clearTimeout(timer);
timer = setTimeout(() => lookup(q), 200);
}
function focusOnMount(node: HTMLElement) {
if (!autofocus) return;
node.focus();
if (node instanceof HTMLInputElement && node.type === 'text') node.select();
if (column.fk) lookup('');
}
function keydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
oncancel?.();
} else if (e.key === 'Enter' && (!long || e.ctrlKey || e.metaKey)) {
e.preventDefault();
e.stopPropagation();
onsave?.();
} else if (e.key === 'Tab' && onsave) {
e.preventDefault();
e.stopPropagation();
onsave(e.shiftKey ? 'prev' : 'next');
}
}
const base =
'w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1 text-sm focus:outline-2 focus:outline-[var(--site-text)]';
const placeholder = $derived(
value === null
? column.hasDefault
? `default${column.defaultExpr ? `: ${column.defaultExpr}` : ''}`
: 'null'
: ''
);
</script>
<div class={['flex items-start gap-1', compact ? '' : 'w-full']}>
{#if kind === 'bool'}
<select
class={base}
value={value ?? ''}
onchange={(e) => (value = e.currentTarget.value === '' ? null : e.currentTarget.value)}
onkeydown={keydown}
{@attach focusOnMount}
aria-label={column.label}
>
{#if column.nullable || column.hasDefault}<option value=""
>{column.hasDefault ? '(default)' : 'null'}</option
>{/if}
<option value="true">true</option>
<option value="false">false</option>
</select>
{:else if kind === 'enum' || (column.uiType === 'select' && column.enumValues.length)}
<select
class={base}
value={value ?? ''}
onchange={(e) => (value = e.currentTarget.value === '' ? null : e.currentTarget.value)}
onkeydown={keydown}
{@attach focusOnMount}
aria-label={column.label}
>
{#if column.nullable || column.hasDefault}<option value=""
>{column.hasDefault ? '(default)' : 'null'}</option
>{/if}
{#each column.enumValues as v (v)}<option value={v}>{v}</option>{/each}
</select>
{:else if long}
<textarea
class={[base, 'min-h-24 font-mono']}
rows={kind === 'json' ? 6 : 3}
value={value ?? ''}
{placeholder}
oninput={(e) => (value = e.currentTarget.value)}
onkeydown={keydown}
{@attach focusOnMount}
aria-label={column.label}></textarea>
{:else}
<input
class={base}
type={kind === 'date'
? 'date'
: kind === 'datetime'
? 'datetime-local'
: kind === 'time'
? 'time'
: kind === 'number'
? 'number'
: 'text'}
step={kind === 'number' ? 'any' : kind === 'datetime' || kind === 'time' ? 1 : undefined}
list={column.fk ? listId : undefined}
value={value ?? ''}
{placeholder}
oninput={(e) => {
value = e.currentTarget.value;
if (column.fk) queueLookup(e.currentTarget.value);
}}
onkeydown={keydown}
{@attach focusOnMount}
aria-label={column.label}
/>
{#if column.fk}
<datalist id={listId}>
{#each options as o (o.value)}<option value={o.value}>{o.label}</option>{/each}
</datalist>
{/if}
{/if}
{#if column.nullable && value !== null}
<button
type="button"
class="shrink-0 rounded px-1.5 py-1 text-xs text-[var(--site-muted)] hover:bg-[var(--site-hover)]"
title="Set to NULL"
onclick={() => (value = null)}>null</button
>
{/if}
</div>

View File

@@ -0,0 +1,72 @@
// Client-side value helpers for the /lists/db grid (shared by the grid and FieldInput).
export type Col = {
name: string;
label: string;
type: string;
kind: string;
uiType: string | null;
nullable: boolean;
hasDefault: boolean;
defaultExpr: string | null;
readOnly: boolean;
enumValues: string[];
pk: boolean;
fk: { schema: string; table: string; column: string } | null;
help: string | null;
};
const pad = (n: number) => String(n).padStart(2, '0');
const withTz = (c: Col) => c.type.includes('with time zone');
/** Raw JSON value from the server -> editor string (null stays null). */
export function toEditor(c: Col, v: unknown): string | null {
if (v === null || v === undefined) return null;
if (c.kind === 'json') return JSON.stringify(v, null, 2);
if (c.kind === 'array') return JSON.stringify(v);
if (c.kind === 'datetime' && typeof v === 'string') {
if (!withTz(c)) return v.slice(0, 19);
const d = new Date(v);
if (Number.isNaN(d.getTime())) return v;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
if (c.kind === 'date' && typeof v === 'string') return v.slice(0, 10);
return String(v);
}
/** Editor string -> value sent to the server. */
export function fromEditor(c: Col, s: string | null): string | null {
if (s === null) return null;
if (c.kind === 'datetime' && withTz(c) && s.trim()) {
const d = new Date(s);
return Number.isNaN(d.getTime()) ? s : d.toISOString();
}
return s;
}
/** Short display text for a grid cell. */
export function display(c: Col, v: unknown): string {
if (v === null || v === undefined) return '';
if (c.kind === 'bool') return v ? '✓' : '✗';
if (c.uiType === 'cents' && typeof v === 'number') return `€${(v / 100).toFixed(2)}`;
if (c.kind === 'datetime' && typeof v === 'string') {
const d = new Date(v);
if (Number.isNaN(d.getTime())) return v;
return withTz(c)
? d.toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })
: v.replace('T', ' ').slice(0, 16);
}
if (typeof v === 'object') {
const s = JSON.stringify(v);
return s.length > 120 ? `${s.slice(0, 117)}…` : s;
}
const s = String(v);
return s.length > 160 ? `${s.slice(0, 157)}…` : s;
}
export function shortType(t: string): string {
return t
.replace('timestamp with time zone', 'timestamptz')
.replace('timestamp without time zone', 'timestamp')
.replace('character varying', 'varchar')
.replace('double precision', 'float8');
}

View File

@@ -1,16 +1,19 @@
<script lang="ts">
import type { ChartPoint, ChartSeries } from './charts';
import { timeGrid, yearLabels, type ChartPoint, type ChartSeries, type GridMode } from './charts';
let {
series,
title,
unit = '€',
marker
marker,
grid
}: {
series: ChartSeries[];
title: string;
unit?: string;
marker?: { date: string; label: string };
/** Vertical gridlines: every month (1 January drawn stronger) or every year. */
grid?: GridMode;
} = $props();
let clientWidth = $state(800);
let width = $derived(Math.max(280, clientWidth));
@@ -26,6 +29,14 @@
let from = $derived(Math.min(...times));
let until = $derived(Math.max(...times));
let dateSpan = $derived(until - from || 1);
let gridLines = $derived(
grid && values.length ? timeGrid(isoDate(from), isoDate(until), grid) : []
);
let axisTicks = $derived(
grid === 'years'
? yearLabels(gridLines, Math.max(2, Math.floor((width - plot.left - plot.right) / 44)))
: ticks()
);
let snapDates = $derived(
[...new Set(series.flatMap((item) => item.points.map((point) => point.date)))].sort()
);
@@ -59,6 +70,9 @@
return { min, max, ticks };
}
function isoDate(ms: number) {
return new Date(ms).toISOString().slice(0, 10);
}
function time(date: string) {
return Date.parse(`${date}T12:00:00Z`);
}
@@ -108,6 +122,9 @@
}
return result;
}
function tickLabel(date: string) {
return grid === 'years' ? date.slice(0, 4) : dateLabel(date);
}
function dateLabel(date: string, withDay = false) {
return new Date(time(date)).toLocaleDateString('en-NL', {
day: withDay ? 'numeric' : undefined,
@@ -145,6 +162,16 @@
onpointerdown={hover}
onpointerleave={() => (hoverDate = null)}
>
{#each gridLines as line (line.date)}
<line
class="grid-x"
class:year={line.year && grid === 'months'}
x1={x(line.date)}
x2={x(line.date)}
y1={plot.top}
y2={height - plot.bottom}
/>
{/each}
{#each scale.ticks as value (value)}
<line
x1={plot.left}
@@ -162,13 +189,13 @@
font-size="11">{format(value)}</text
>
{/each}
{#each ticks() as tick (tick)}
{#each axisTicks as tick (tick)}
<text
x={x(tick)}
y={height - 8}
text-anchor="middle"
fill="var(--site-muted)"
font-size="11">{dateLabel(tick)}</text
font-size="11">{tickLabel(tick)}</text
>
{/each}
{#if marker && time(marker.date) >= from && time(marker.date) <= until}
@@ -294,6 +321,18 @@
overflow: visible;
user-select: none;
}
/* Vertical time gridlines: quieter than the value gridlines so the data stays in front. */
.grid-x {
stroke: var(--site-border);
stroke-width: 1;
stroke-opacity: 0.55;
shape-rendering: crispEdges;
}
.grid-x.year {
stroke: var(--site-muted);
stroke-width: 1.5;
stroke-opacity: 0.4;
}
.tooltip {
position: absolute;
top: 4px;

View File

@@ -4,6 +4,9 @@ import {
accountColor,
balanceSeries,
forecastSeries,
parseHistoryRange,
timeGrid,
yearLabels,
utilityEstimate,
wealthProjection,
wealthTimeline
@@ -257,3 +260,93 @@ describe('money tracker calculations', () => {
).toBe(30);
});
});
describe('history range', () => {
it('starts lines at the range start with the balance they had then', async () => {
const { clipSeries, historyStart } = await import('./charts');
expect(historyStart('1y', '2026-10-24')).toBe('2025-10-24');
expect(historyStart('all', '2026-10-24')).toBeNull();
const series = [
{
label: 'Knab',
color: 'x',
points: [
{ date: '2021-08-24', value: 100 },
{ date: '2025-01-01', value: 50 },
{ date: '2026-01-01', value: 70 }
]
}
];
expect(clipSeries(series, '2025-10-24')[0].points).toEqual([
{ date: '2025-10-24', value: 50 },
{ date: '2026-01-01', value: 70 }
]);
expect(clipSeries(series, null)).toBe(series);
});
it('leaves accounts with includeInTotal false out of the totals but keeps their own line', () => {
const accounts = [
{ id: 1, name: 'Bank', kind: 'spendable', openingBalanceCents: 1000 },
{ id: 2, name: 'Parents', kind: 'spendable', openingBalanceCents: 0, includeInTotal: false }
];
const actual = [
{ accountId: 2, date: '2019-03-01', amountCents: 1_000_000 },
{ accountId: 2, date: '2019-04-01', amountCents: -1_000_000 },
{ accountId: 1, date: '2019-04-01', amountCents: 500 }
];
const series = balanceSeries(accounts, actual);
expect(series[1].points.map((point) => point.value)).toEqual([1_000_000, 0]);
expect(series.at(-1)!.points.map((point) => point.value)).toEqual([1000, 1500]);
const balances = accountBalances(accounts, actual);
const future = [
{ accountId: 2, date: '2026-10-01', amountCents: 9999 },
{ accountId: 1, date: '2026-10-01', amountCents: 100 }
];
const projection = wealthProjection(accounts, balances, future, [], [], null, '2026-09-23');
expect(projection[0].points[0].value).toBe(1500);
expect(projection[0].points.at(-1)!.value).toBe(1600);
const [, spendable] = wealthTimeline(accounts, actual, projection, '2026-09-23', 'split');
expect(spendable.points.slice(0, 2).map((point) => point.value)).toEqual([1000, 1500]);
const byAccount = wealthTimeline(accounts, actual, projection, '2026-09-23', 'accounts');
expect(byAccount.map((line) => line.label)).toEqual(['Bank', 'Parents', 'Total']);
expect(byAccount.at(-1)!.points[0].value).toBe(1000);
});
it('reads the history range from the URL, defaulting to one year', () => {
expect(parseHistoryRange('3y')).toBe('3y');
expect(parseHistoryRange('all')).toBe('all');
expect(parseHistoryRange('1y')).toBe('1y');
expect(parseHistoryRange('5y')).toBe('1y');
expect(parseHistoryRange(null)).toBe('1y');
});
it('puts a gridline on every month start and marks 1 January as a year line', () => {
const lines = timeGrid('2025-10-24', '2026-03-01', 'months');
expect(lines).toEqual([
{ date: '2025-11-01', year: false },
{ date: '2025-12-01', year: false },
{ date: '2026-01-01', year: true },
{ date: '2026-02-01', year: false },
{ date: '2026-03-01', year: false }
]);
expect(timeGrid('2025-01-01', '2025-02-15', 'months').map((line) => line.date)).toEqual([
'2025-01-01',
'2025-02-01'
]);
});
it('puts only year lines on the all-time grid and thins crowded year labels', () => {
const lines = timeGrid('2007-02-01', '2027-10-24', 'years');
expect(lines).toHaveLength(20);
expect(lines.every((line) => line.year && line.date.endsWith('-01-01'))).toBe(true);
expect(lines[0].date).toBe('2008-01-01');
expect(yearLabels(lines, 30)).toHaveLength(20);
expect(yearLabels(lines, 6).map((date) => date.slice(0, 4))).toEqual([
'2010',
'2015',
'2020',
'2025'
]);
expect(timeGrid('2026-01-02', '2026-01-01', 'months')).toEqual([]);
});
});

View File

@@ -15,10 +15,24 @@ export function accountColor(account: { color?: string | null }, index: number):
return account.color || palette[index % palette.length];
}
/** Accounts kept out of net worth and total wealth (`includeInTotal: false`). */
export function excludedIds(accounts: { id: number; includeInTotal?: boolean }[]): Set<number> {
return new Set(
accounts.filter((account) => account.includeInTotal === false).map((account) => account.id)
);
}
export function balanceSeries(
accounts: { id: number; name: string; openingBalanceCents: number; color?: string | null }[],
accounts: {
id: number;
name: string;
openingBalanceCents: number;
color?: string | null;
includeInTotal?: boolean;
}[],
transactions: { accountId: number; date: string; amountCents: number }[]
): ChartSeries[] {
const excluded = excludedIds(accounts);
const ordered = [...transactions].sort((a, b) => a.date.localeCompare(b.date));
const balances = new Map(accounts.map((account) => [account.id, account.openingBalanceCents]));
const accountPoints = new Map(accounts.map((account) => [account.id, [] as ChartPoint[]]));
@@ -36,7 +50,10 @@ export function balanceSeries(
accountPoints.get(account.id)!.push({ date, value: balances.get(account.id)! });
combinedPoints.push({
date,
value: [...balances.values()].reduce((sum, balance) => sum + balance, 0)
value: [...balances].reduce(
(sum, [id, balance]) => (excluded.has(id) ? sum : sum + balance),
0
)
});
}
return [
@@ -86,7 +103,12 @@ export function forecastSeries(
return [{ label: 'Projected total', color: '#0070f3', points }];
}
type ProjectionAccount = { id: number; kind: string; openingBalanceCents: number };
type ProjectionAccount = {
id: number;
kind: string;
openingBalanceCents: number;
includeInTotal?: boolean;
};
type ProjectionMovement = { date: string; amountCents: number; accountId: number | null };
type ProjectionRecurring = {
amountCents: number;
@@ -114,6 +136,8 @@ export function wealthProjection(
settings: ProjectionSettings | null,
today: string
): ChartSeries[] {
const excluded = excludedIds(accounts);
const counted = (accountId: number | null) => accountId === null || !excluded.has(accountId);
const accountKind = new Map(accounts.map((account) => [account.id, account.kind]));
const investmentId = settings?.investmentAccountId ?? null;
const validInvestmentId =
@@ -121,6 +145,7 @@ export function wealthProjection(
let spendable = 0;
let invested = 0;
for (const account of accounts) {
if (excluded.has(account.id)) continue;
const value = balances.get(account.id) ?? 0;
if (account.kind === 'investment') invested += value;
else spendable += value;
@@ -129,7 +154,9 @@ export function wealthProjection(
const totalPoints: ChartPoint[] = [{ date: today, value: spendable + invested }];
const spendablePoints: ChartPoint[] = [{ date: today, value: spendable }];
const investedPoints: ChartPoint[] = [{ date: today, value: invested }];
const movements = [...futureTransactions, ...forecasts].filter((entry) => entry.date > today);
const movements = [...futureTransactions, ...forecasts].filter(
(entry) => entry.date > today && counted(entry.accountId)
);
const latestMovement = movements.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest),
''
@@ -162,6 +189,7 @@ export function wealthProjection(
}
if (settings?.includeRecurring !== false && index > 0) {
for (const item of recurring) {
if (!counted(item.accountId)) continue;
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
continue;
const value = Math.round(monthlyCents(item.amountCents, item.cadence));
@@ -215,6 +243,7 @@ export function wealthTimeline(
kind: string;
openingBalanceCents: number;
color?: string | null;
includeInTotal?: boolean;
}[],
actualTransactions: { accountId: number; date: string; amountCents: number }[],
projection: ChartSeries[],
@@ -229,7 +258,7 @@ export function wealthTimeline(
date,
value: accounts.reduce(
(sum, account, accountIndex) =>
(account.kind === 'investment') === investment
account.includeInTotal !== false && (account.kind === 'investment') === investment
? sum + accountLines[accountIndex].points[index].value
: sum,
0
@@ -306,3 +335,70 @@ export function utilityEstimate(
differenceCents: advancesCents - estimatedCents
};
}
export type HistoryRange = '1y' | '3y' | 'all';
/** First date shown for a history range (null = everything). */
export function historyStart(range: HistoryRange, today: string): string | null {
if (range === 'all') return null;
const years = range === '1y' ? 1 : 3;
return `${Number(today.slice(0, 4)) - years}${today.slice(4)}`;
}
/**
* Hides history before `from`, starting each line at `from` with the balance it had then,
* so a long bank history doesn't squash the recent months.
*/
export function clipSeries(series: ChartSeries[], from: string | null): ChartSeries[] {
if (!from) return series;
return series.map((line) => {
const before = line.points.filter((point) => point.date < from);
const after = line.points.filter((point) => point.date >= from);
const carried = before.at(-1);
const points =
carried && after[0]?.date !== from ? [{ date: from, value: carried.value }, ...after] : after;
return { ...line, points };
});
}
/** Reads a `?range=` value, falling back to one year for anything unknown. */
export function parseHistoryRange(value: string | null | undefined): HistoryRange {
return value === '3y' || value === 'all' ? value : '1y';
}
export type GridMode = 'months' | 'years';
export type GridLine = { date: string; year: boolean };
/**
* Vertical gridlines between two dates (inclusive): the first of every month (`months`),
* or only 1 January (`years`). Lines on 1 January are flagged so they can be drawn stronger.
*/
export function timeGrid(from: string, until: string, mode: GridMode): GridLine[] {
const lines: GridLine[] = [];
if (!from || !until || from > until) return lines;
let year = Number(from.slice(0, 4));
let month = Number(from.slice(5, 7)) - 1;
if (from.slice(8) !== '01') month++;
for (let guard = 0; guard < 1200; guard++) {
if (month > 11) {
month = 0;
year++;
}
if (mode === 'years' && month !== 0) {
month = 12;
continue;
}
const date = `${year}-${String(month + 1).padStart(2, '0')}-01`;
if (date > until) break;
lines.push({ date, year: month === 0 });
month++;
}
return lines;
}
/** Year labels for the `years` grid, thinned to every 2nd/5th/10th year when crowded. */
export function yearLabels(lines: GridLine[], maxLabels: number): string[] {
const years = lines.filter((line) => line.year).map((line) => line.date);
const step = [1, 2, 5, 10, 20].find((size) => Math.ceil(years.length / size) <= maxLabels) ?? 50;
return years.filter((date) => Number(date.slice(0, 4)) % step === 0);
}

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { sortWishlist, tierClass } from './wishlist';
describe('wish list order', () => {
it('sorts by tier descending, then price descending, unpriced and untiered last', () => {
const items = [
{ id: 1, priority: 3, estimatedCents: 5000 },
{ id: 2, priority: 5, estimatedCents: 1000 },
{ id: 3, priority: 5, estimatedCents: null },
{ id: 4, priority: 5, estimatedCents: 90000 },
{ id: 5, priority: null, estimatedCents: 99999 },
{ id: 6, priority: 1, estimatedCents: 200 },
{ id: 7, priority: 3, estimatedCents: 25000 },
{ id: 8, priority: 3, estimatedCents: 5000 }
];
expect(sortWishlist(items).map((item) => item.id)).toEqual([4, 2, 3, 7, 1, 8, 6, 5]);
expect(items[0].id).toBe(1);
});
it('maps tiers to colour classes', () => {
expect(tierClass(5)).toBe('tier-5');
expect(tierClass(1)).toBe('tier-1');
expect(tierClass(null)).toBe('tier-none');
expect(tierClass(9)).toBe('tier-none');
});
});

View File

@@ -0,0 +1,25 @@
export type WishSortable = {
id?: number;
priority: number | null;
estimatedCents: number | null;
};
/**
* Wish list order: tier 5 first down to tier 1, then the most expensive first within a tier.
* Items without a tier sink below every tier; unpriced items sink to the end of their tier.
*/
export function sortWishlist<T extends WishSortable>(items: readonly T[]): T[] {
const rank = (value: number | null) => (value == null ? -Infinity : value);
return [...items].sort(
(a, b) =>
rank(b.priority) - rank(a.priority) ||
(a.estimatedCents == null ? 1 : 0) - (b.estimatedCents == null ? 1 : 0) ||
(b.estimatedCents ?? 0) - (a.estimatedCents ?? 0) ||
(a.id ?? 0) - (b.id ?? 0)
);
}
/** CSS class for a tier colour (tier-1 … tier-5), or "tier-none". */
export function tierClass(priority: number | null): string {
return priority != null && priority >= 1 && priority <= 5 ? `tier-${priority}` : 'tier-none';
}

21
src/lib/projects.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { visibleTrackers } from './projects';
describe('trackers', () => {
it('lists tasks, money, books first for the owner', () => {
expect(
visibleTrackers(true)
.map((t) => t.name)
.slice(0, 4)
).toEqual(['tasks', 'money', 'books', 'health']);
});
it('hides private trackers when logged out', () => {
const names = visibleTrackers(false).map((t) => t.name);
expect(names).not.toContain('tasks');
expect(names).not.toContain('health');
expect(names.slice(0, 2)).toEqual(['money', 'books']);
});
it('uses short single-word names', () => {
for (const t of visibleTrackers(true)) expect(t.name).toMatch(/^[a-z]+$/);
});
});

View File

@@ -32,70 +32,96 @@ export const projects = [
}
] as const;
// Trackers keep their existing /projects/... URLs (no moves, so no redirects needed).
export const trackers = [
// Trackers, in display order. Names are short single nouns. Most keep their existing
// /projects/... URLs (no moves, so no redirects needed). `private` trackers are 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.
const allTrackers = [
{
name: 'game tracker',
subtitle: 'every pokemon in every game, and call of duty camo mastery',
href: '/projects/game-tracker',
external: false
name: 'tasks',
subtitle: 'private: to-dos, goals and my TickTick history',
href: '/trackers/tasks',
external: false,
private: true
},
{
name: 'money tracker',
name: 'money',
subtitle: 'a manual ledger for accounts, bills and utilities',
href: '/projects/money-tracker',
external: false
},
{
name: 'house tracker',
subtitle: 'Dutch rental and sale listings, with alerts',
href: '/projects/house-tracker',
external: false
},
{
name: 'cs prices',
subtitle: 'Counter-Strike market prices across time',
href: '/projects/cs-prices',
external: false
},
{
name: 'energy',
subtitle: 'Dutch electricity mix, utilization and gas storage',
href: '/projects/energy',
external: false
},
{
name: 'animaldex',
subtitle: 'add neighborhood pets to the world map',
href: '/projects/animaldex',
external: false
href: '/trackers/money',
external: false,
private: false
},
{
name: 'books',
subtitle: 'my library: what I am reading, have read and want to read',
href: '/trackers/books',
external: false
external: false,
private: false
},
{
name: 'health',
subtitle: 'private: body, workouts, sleep, meds and vaccinations',
href: '/trackers/health',
external: false,
private: true
},
{
name: 'energy',
subtitle: 'Dutch electricity mix, utilization and gas storage',
href: '/projects/energy',
external: false,
private: false
},
{
name: 'games',
subtitle: 'every pokemon in every game, and call of duty camo mastery',
href: '/projects/game-tracker',
external: false,
private: false
},
{
name: 'houses',
subtitle: 'Dutch rental and sale listings, with alerts',
href: '/projects/house-tracker',
external: false,
private: false
},
{
name: 'skins',
subtitle: 'Counter-Strike skin market prices across time',
href: '/projects/cs-prices',
external: false,
private: false
},
{
name: 'pets',
subtitle: 'animaldex: add neighborhood pets to the world map',
href: '/projects/animaldex',
external: false,
private: false
},
{
name: 'lists',
subtitle: 'books, games, places and other things worth keeping',
href: '/lists',
external: false
}
] as const;
// Owner-only trackers: shown in the header, home page and /trackers only to the logged-in
// owner (layout data `isOwner`), never in the sitemap. Their routes 404 for everyone else.
export const privateTrackers = [
{
name: 'health',
subtitle: 'private: body, workouts, sleep, meds and vaccinations',
href: '/trackers/health',
external: false
external: false,
private: false
}
] as const;
type Tracker = { name: string; subtitle: string; href: string; external: boolean };
const strip = ({ name, subtitle, href, external }: Tracker): Tracker => ({
name,
subtitle,
href,
external
});
/** Public trackers (sitemap, logged-out navigation). */
export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private).map(strip);
/** Owner-only trackers. */
export const privateTrackers: readonly Tracker[] = allTrackers.filter((t) => t.private).map(strip);
const ownerTrackers: readonly Tracker[] = allTrackers.map(strip);
export function visibleTrackers(isOwner: boolean): readonly Tracker[] {
return isOwner ? [...trackers, ...privateTrackers] : trackers;
return isOwner ? ownerTrackers : trackers;
}

View File

@@ -120,6 +120,22 @@ describe('account mapping', () => {
['b', 'Revolut']
]);
});
it('maps another bank to its own tracker account without the joint heuristic', () => {
const plan = planAccountMapping(
[
{
uid: 'n',
account_id: { iban: 'NL01ABNA0123456789' },
name: 'S Runge & J Kim',
currency: 'EUR'
}
],
[...tracker, { id: 7, name: 'ABN Amro', scope: 'personal', iban: null, external_uid: null }],
{},
{ defaultAccount: 'ABN Amro', joint: false }
);
expect(plan.map((p) => [p.bank.uid, p.tracker?.name ?? null])).toEqual([['n', 'ABN Amro']]);
});
});
describe('enable banking client', () => {

View File

@@ -11,6 +11,7 @@ import type {
SessionResponse
} from './enable-banking';
import { EnableBankingError } from './enable-banking';
import { findSumMatch } from './reconcile';
export type MappedTransaction = {
externalId: string;
@@ -86,6 +87,9 @@ 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 const isOpeningBalance = (description: string) =>
/^(start|opening) balance/i.test(description.trim());
export type ExistingRow = { id: number; date: string; amountCents: number; description: string };
/**
@@ -143,13 +147,22 @@ type TrackerAccount = {
/**
* 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.
* account (Revolut only), first remaining EUR current account -> `defaultAccount`
* ("Revolut" unless given). Others are skipped.
*/
export function planAccountMapping(
bankAccounts: BankAccount[],
tracker: TrackerAccount[],
overrides: Record<string, string> = {}
overrides: Record<string, string> = {},
options: {
/** Tracker account for the first personal EUR current account (default "Revolut"). */
defaultAccount?: string;
/** Route joint-looking accounts to the joint-scoped tracker account (Revolut only). */
joint?: boolean;
} = {}
) {
const defaultAccount = (options.defaultAccount ?? 'Revolut').toLowerCase();
const joint = options.joint ?? defaultAccount === 'revolut';
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 }[] = [];
@@ -188,6 +201,7 @@ export function planAccountMapping(
const rest = pending.filter(
(bank) =>
!(
joint &&
eur(bank) &&
looksJoint(bank) &&
take(
@@ -201,7 +215,7 @@ export function planAccountMapping(
if (
eur(bank) &&
bank.cash_account_type !== 'SVGS' &&
take(bank, byName.get('revolut'), 'personal EUR account')
take(bank, byName.get(defaultAccount), 'personal EUR account')
)
continue;
plan.push({ bank, tracker: null, reason: 'no match (use --map uid=Account)' });
@@ -274,6 +288,8 @@ export type SyncOptions = {
/** Match against the whole history of hand-entered rows (one-time), not just the window. */
reconcile?: boolean;
from?: string;
/** Fetch the longest history the bank serves (automatic on an account's first sync). */
full?: boolean;
accountName?: string;
log?: (line: string) => void;
today?: string;
@@ -285,7 +301,11 @@ export type AccountSyncResult = {
booked: number;
inserted: number;
matched: number;
/** Hand-entered rows replaced because they equal the sum of 2-3 bank payments. */
absorbed: number;
alreadyKnown: number;
/** Oldest booked date the bank returned. */
historyFrom?: string;
balance?: string;
/** Bank balance minus the tracker's own rows, absorbed by the adjustment row. */
adjustmentCents?: number;
@@ -318,6 +338,7 @@ export async function syncBankAccounts(
booked: 0,
inserted: 0,
matched: 0,
absorbed: 0,
alreadyKnown: 0
};
results.push(result);
@@ -325,22 +346,31 @@ export async function syncBankAccounts(
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)));
// First sync of an account (or --full): ask for the longest history the bank serves.
const longest = options.full || (!last && !options.from);
const dateFrom = options.from ?? (last ? addDays(last, -7) : undefined);
let raw: BankTransaction[];
try {
raw = await client.transactions(account.external_uid, { dateFrom });
raw = await client.transactions(
account.external_uid,
longest ? { strategy: 'longest', dateFrom: options.from } : { 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`);
log(
`${account.name}: ${dateFrom ?? 'full history'} is not available; 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;
result.historyFrom = mapped.reduce<string | undefined>(
(min, t) => (!min || t.date < min ? t.date : min),
undefined
);
if (!mapped.length) {
await syncBalance(sql, client, account, result, options, today, log);
continue;
@@ -355,11 +385,14 @@ export async function syncBankAccounts(
).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)}`;
// Opening-balance rows are not payments; never match or replace them.
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)}`
).filter((row) => !isOpeningBalance(row.description));
const used = new Set<number>();
const inserts: MappedTransaction[] = [];
const updates: { id: number; tx: MappedTransaction }[] = [];
@@ -376,10 +409,31 @@ export async function syncBankAccounts(
updates.push({ id: match.id, tx });
} else inserts.push(tx);
}
// A hand entry can stand for several bank payments ("To Joint 209" = 200 + 3 + 6):
// replace it with the real payments instead of keeping both.
const absorbed: { row: ExistingRow; parts: MappedTransaction[] }[] = [];
const takenInserts = new Set<number>();
for (const row of candidates) {
if (used.has(row.id)) continue;
const parts = findSumMatch(row, inserts, takenInserts);
if (!parts) continue;
parts.forEach((index) => takenInserts.add(index));
used.add(row.id);
absorbed.push({ row, parts: parts.map((index) => inserts[index]) });
}
for (const { row, parts } of absorbed)
log(
`${account.name}: hand entry #${row.id} ${row.date} "${row.description}" ${row.amountCents} = ` +
parts.map((p) => `${p.date} ${p.description} ${p.amountCents}`).join(' + ') +
(options.dryRun ? ' (would replace)' : ' (replaced)')
);
result.matched = updates.length;
result.absorbed = absorbed.length;
result.inserted = inserts.length;
if (!options.dryRun) {
await sql.begin(async (q) => {
for (const { row } of absorbed)
await q`delete from money.transactions where id = ${row.id} and external_id is null`;
for (const { id, tx } of updates) {
// Keep Stan's own description/category; attach the bank identity.
await q`
@@ -407,14 +461,22 @@ export async function syncBankAccounts(
}
}
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'`;
// Each bank has its own session; only touch the sessions whose accounts were synced.
const sessions = await sql<{ id: number; accounts: { uid: string | null }[] }[]>`
select id, accounts from money.bank_sessions where status = 'authorized'`;
for (const session of sessions) {
const uids = new Set(session.accounts.map((a) => a.uid).filter(Boolean));
const mine = results.filter((_, index) => uids.has(selected[index].external_uid));
if (!mine.length) continue;
const failed = mine.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 id = ${session.id}`;
}
}
return results;
}

View File

@@ -1,6 +1,6 @@
// Highlights and notes (books.notes). A row with `highlight` is a highlight from the reader
// (optionally with a note); a row with only `note` is a free-form note on the book.
import { z } from 'zod';
import * as z from 'zod';
import { and, eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { bookFiles, bookNotes, bookWorks } from '#lib/server/db/schema/index.js';

View File

@@ -0,0 +1,758 @@
// Owner-only database browser/editor behind /lists/db. Every table and column name used
// in SQL comes from pg_catalog (looked up with bound parameters) and is quoted with
// sql.identifier; every value is a bound parameter. Access rules live in data-policy.ts.
import { sql, type SQL } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import {
coerce,
hiddenColumn,
plausibleIdent,
safeTypeName,
tableAccess,
typeKind,
writeAllowed,
type Access,
type Filter,
type Kind
} from '#lib/server/data-policy.js';
export type TableSummary = {
schema: string;
name: string;
displayName: string;
description: string | null;
relkind: string;
access: Access;
rows: number | null;
approximate: boolean;
listSlug: string | null;
};
export type ColumnInfo = {
name: string;
label: string;
type: string;
kind: Kind;
uiType: string | null;
nullable: boolean;
hasDefault: boolean;
defaultExpr: string | null;
/** identity ALWAYS / generated columns cannot be written */
readOnly: boolean;
enumValues: string[];
pk: boolean;
fk: { schema: string; table: string; column: string } | null;
help: string | null;
};
export type TableInfo = TableSummary & {
oid: number;
columns: ColumnInfo[];
primaryKey: string[];
canInsert: boolean;
canUpdate: boolean;
canDelete: boolean;
isList: boolean;
};
type CatalogRow = {
oid: number;
schema: string;
name: string;
relkind: string;
estimate: number;
can_insert: boolean;
can_update: boolean;
can_delete: boolean;
has_pk: boolean;
comment: string | null;
display_name: string | null;
description: string | null;
meta_kind: string | null;
slug: string | null;
archived: boolean;
};
const MAX_EXACT = 200_000;
function catalogQuery(where: SQL) {
return sql`
SELECT c.oid::int AS oid, n.nspname AS schema, c.relname AS name, c.relkind::text AS relkind,
greatest(c.reltuples, 0)::bigint::float8 AS estimate,
has_table_privilege(c.oid, 'INSERT') AS can_insert,
has_table_privilege(c.oid, 'UPDATE') AS can_update,
has_table_privilege(c.oid, 'DELETE') AS can_delete,
EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary) AS has_pk,
obj_description(c.oid, 'pg_class') AS comment,
mt.display_name, mt.description, mt.kind AS meta_kind, mt.slug,
(mt.archived_at IS NOT NULL) AS archived
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN meta.tables mt ON mt.schema_name = n.nspname AND mt.table_name = c.relname
WHERE c.relkind IN ('r', 'p', 'v', 'm') AND NOT c.relispartition
AND n.nspname <> 'information_schema' AND n.nspname NOT LIKE 'pg\\_%'
AND has_schema_privilege(n.oid, 'USAGE') AND has_table_privilege(c.oid, 'SELECT')
AND ${where}
ORDER BY n.nspname, c.relname`;
}
function summarise(r: CatalogRow): Omit<TableSummary, 'rows' | 'approximate'> {
const access = tableAccess({
schema: r.schema,
name: r.name,
relkind: r.relkind,
hasPrimaryKey: r.has_pk,
canInsert: r.can_insert,
canUpdate: r.can_update,
canDelete: r.can_delete
});
return {
schema: r.schema,
name: r.name,
displayName: r.display_name ?? r.name.replace(/_/g, ' '),
description: r.description ?? r.comment,
relkind: r.relkind,
access: r.archived ? 'locked' : access,
listSlug: r.meta_kind === 'list' && !r.archived ? r.slug : null
};
}
const rel = (schema: string, name: string) =>
sql`${sql.identifier(schema)}.${sql.identifier(name)}`;
/** Every table/view the app role can read, minus hidden ones, with row counts. */
export async function listTables(): Promise<TableSummary[]> {
const rows = (await db.execute<CatalogRow>(catalogQuery(sql`true`))) as unknown as CatalogRow[];
const visible = rows
.map((r) => ({ r, s: summarise(r) }))
.filter(({ s }) => s.access !== 'hidden');
// exact counts for real tables up to MAX_EXACT rows; estimates for huge ones; views are
// counted only when opened (some aggregate big tables).
const exact = visible.filter(
({ r }) => (r.relkind === 'r' || r.relkind === 'p') && r.estimate < MAX_EXACT
);
const counts = new Map<string, number>();
if (exact.length) {
const res = await db.transaction(async (tx) => {
await tx.execute(sql`SET LOCAL statement_timeout = '10s'`);
return tx.execute<{ k: string; n: number }>(
sql.join(
exact.map(
({ r }) =>
sql`SELECT ${`${r.schema}.${r.name}`}::text AS k, count(*)::int AS n FROM ${rel(r.schema, r.name)}`
),
sql` UNION ALL `
)
);
});
for (const c of res as unknown as { k: string; n: number }[]) counts.set(c.k, Number(c.n));
}
return visible.map(({ r, s }) => {
const n = counts.get(`${r.schema}.${r.name}`);
return n !== undefined
? { ...s, rows: n, approximate: false }
: r.relkind === 'r' || r.relkind === 'p'
? { ...s, rows: r.estimate, approximate: true }
: { ...s, rows: null, approximate: false };
});
}
type ColumnRow = {
name: string;
type: string;
notnull: boolean;
default_expr: string | null;
identity: string;
generated: string;
is_enum: boolean;
enum_values: string[] | null;
comment: string | null;
pk_pos: number | null;
fk_schema: string | null;
fk_table: string | null;
fk_column: string | null;
ui_type: string | null;
label: string | null;
help_text: string | null;
meta_hidden: boolean | null;
select_values: string[] | null;
};
/** One table by schema + name (bound parameters), or null when unknown or hidden. */
export async function getTable(schema: string, name: string): Promise<TableInfo | null> {
if (!plausibleIdent(schema) || !plausibleIdent(name)) return null;
const [r] = (await db.execute<CatalogRow>(
catalogQuery(sql`n.nspname = ${schema} AND c.relname = ${name}`)
)) as unknown as CatalogRow[];
if (!r) return null;
const s = summarise(r);
if (s.access === 'hidden') return null;
const cols = (await db.execute<ColumnRow>(sql`
SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type, a.attnotnull AS notnull,
pg_get_expr(d.adbin, d.adrelid) AS default_expr, a.attidentity::text AS identity,
a.attgenerated::text AS generated,
(et.typtype = 'e') AS is_enum,
(SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder) FROM pg_enum e WHERE e.enumtypid = et.oid) AS enum_values,
col_description(a.attrelid, a.attnum) AS comment,
(SELECT array_position(i.indkey::int2[], a.attnum) FROM pg_index i
WHERE i.indrelid = a.attrelid AND i.indisprimary) AS pk_pos,
fk.fk_schema, fk.fk_table, fk.fk_column,
mc.ui_type, mc.label, mc.help_text, mc.hidden AS meta_hidden,
CASE WHEN jsonb_typeof(mc.options->'values') = 'array'
THEN ARRAY(SELECT jsonb_array_elements_text(mc.options->'values')) END AS select_values
FROM pg_attribute a
JOIN pg_type t ON t.oid = a.atttypid
LEFT JOIN pg_type et ON et.oid = CASE WHEN t.typelem <> 0 AND t.typcategory = 'A' THEN t.typelem ELSE t.oid END
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
LEFT JOIN LATERAL (
SELECT fn.nspname AS fk_schema, fc.relname AS fk_table, fa.attname AS fk_column
FROM pg_constraint k
JOIN pg_class fc ON fc.oid = k.confrelid
JOIN pg_namespace fn ON fn.oid = fc.relnamespace
JOIN pg_attribute fa ON fa.attrelid = k.confrelid AND fa.attnum = k.confkey[1]
WHERE k.conrelid = a.attrelid AND k.contype = 'f' AND cardinality(k.conkey) = 1
AND k.conkey[1] = a.attnum
LIMIT 1) fk ON true
LEFT JOIN meta.tables mt ON mt.schema_name = ${schema} AND mt.table_name = ${name}
LEFT JOIN meta.columns mc ON mc.table_id = mt.id AND mc.column_name = a.attname
WHERE a.attrelid = ${r.oid} AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum`)) as unknown as ColumnRow[];
const columns: ColumnInfo[] = cols
.filter((c) => !hiddenColumn(c.name) && safeTypeName(c.type))
.map((c) => ({
name: c.name,
label: c.label ?? c.name.replace(/_/g, ' '),
type: c.type,
kind: typeKind(c.type, Boolean(c.is_enum)),
uiType: c.ui_type,
nullable: !c.notnull,
hasDefault: c.default_expr !== null || c.identity !== '' || c.generated !== '',
defaultExpr: c.default_expr,
readOnly: c.identity === 'a' || c.generated !== '',
enumValues: c.enum_values ?? (c.ui_type === 'select' ? (c.select_values ?? []) : []),
pk: c.pk_pos !== null,
fk: c.fk_schema ? { schema: c.fk_schema, table: c.fk_table!, column: c.fk_column! } : null,
help: c.help_text ?? c.comment
}));
const primaryKey = cols
.filter((c) => c.pk_pos !== null)
.sort((a, b) => a.pk_pos! - b.pk_pos!)
.map((c) => c.name);
// a key column that is hidden or has an odd type makes rows unaddressable: read-only
const keyUsable =
primaryKey.length > 0 && primaryKey.every((k) => columns.some((c) => c.name === k));
return {
...s,
access: s.access !== 'locked' && !keyUsable ? 'locked' : s.access,
rows: null,
approximate: false,
oid: r.oid,
columns,
primaryKey,
canInsert: r.can_insert,
canUpdate: r.can_update,
canDelete: r.can_delete,
isList: s.listSlug !== null
};
}
// ---------- reading ----------
export type RowQuery = {
q?: string;
filters?: Filter[];
sort?: string;
dir?: 'asc' | 'desc';
limit?: number;
offset?: number;
};
function colRef(t: TableInfo, name: string) {
const c = t.columns.find((x) => x.name === name);
if (!c) throw new DataError(400, `Unknown column ${name}.`);
return { c, ref: sql`t.${sql.identifier(c.name)}` };
}
function cast(value: string | null, c: ColumnInfo) {
return sql`CAST(${value} AS ${sql.raw(c.type)})`;
}
function whereClause(t: TableInfo, q: RowQuery): SQL {
const parts: SQL[] = [];
const search = q.q?.trim();
if (search) {
const texts = t.columns.map((c) => sql`t.${sql.identifier(c.name)}::text`);
parts.push(
sql`concat_ws(' ', ${sql.join(texts, sql`, `)}) ILIKE ${`%${search.replace(/[\\%_]/g, '\\$&')}%`}`
);
}
for (const f of q.filters ?? []) {
const { c, ref } = colRef(t, f.column);
if (f.op === 'null') parts.push(sql`${ref} IS NULL`);
else if (f.op === 'notnull') parts.push(sql`${ref} IS NOT NULL`);
else if (f.op === 'contains')
parts.push(sql`${ref}::text ILIKE ${`%${f.value.replace(/[\\%_]/g, '\\$&')}%`}`);
else {
const v = coerce(c.kind, f.value, c.label);
if (!v.ok) throw new DataError(400, v.error);
const val = c.kind === 'json' ? sql`${v.value}::jsonb` : cast(v.value, c);
const lhs = c.kind === 'json' ? sql`${ref}::jsonb` : ref;
const op = { eq: '=', ne: 'IS DISTINCT FROM', gt: '>', gte: '>=', lt: '<', lte: '<=' }[f.op];
parts.push(sql`${lhs} ${sql.raw(op)} ${val}`);
}
}
return parts.length ? sql`WHERE ${sql.join(parts, sql` AND `)}` : sql``;
}
function orderClause(t: TableInfo, q: RowQuery): SQL {
const keys: SQL[] = [];
if (q.sort) {
const { ref } = colRef(t, q.sort);
keys.push(sql`${ref} ${sql.raw(q.dir === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST')}`);
}
for (const k of t.primaryKey.length ? t.primaryKey : [])
if (k !== q.sort)
keys.push(
sql`t.${sql.identifier(k)} ${sql.raw(q.dir === 'desc' && !q.sort ? 'DESC' : 'ASC')}`
);
return keys.length ? sql`ORDER BY ${sql.join(keys, sql`, `)}` : sql``;
}
/** to_jsonb of the visible columns only (hidden credential columns are never selected). */
function rowJson(t: TableInfo) {
// jsonb_build_object takes at most 100 arguments: build it in chunks of 50 columns
const chunks: SQL[] = [];
for (let i = 0; i < t.columns.length; i += 50)
chunks.push(
sql`jsonb_build_object(${sql.join(
t.columns.slice(i, i + 50).map((c) => sql`${c.name}::text, t.${sql.identifier(c.name)}`),
sql`, `
)})`
);
return chunks.length ? sql.join(chunks, sql` || `) : sql`'{}'::jsonb`;
}
export type RowsResult = {
rows: Record<string, unknown>[];
total: number | null;
approximate: boolean;
fkLabels: Record<string, Record<string, string>>;
};
export async function readRows(t: TableInfo, q: RowQuery): Promise<RowsResult> {
const limit = Math.min(Math.max(q.limit ?? 50, 1), 500);
const offset = Math.max(q.offset ?? 0, 0);
const where = whereClause(t, q);
const filtered = Boolean(q.q?.trim() || q.filters?.length);
return db.transaction(async (tx) => {
await tx.execute(sql`SET TRANSACTION READ ONLY`);
await tx.execute(sql`SET LOCAL statement_timeout = '15s'`);
const select = rowJson(t);
const rows = (await tx.execute<{ r: Record<string, unknown> }>(
sql`SELECT ${select} AS r FROM ${rel(t.schema, t.name)} t ${where} ${orderClause(t, q)} LIMIT ${limit} OFFSET ${offset}`
)) as unknown as { r: Record<string, unknown> }[];
let total: number | null = null;
let approximate = false;
const [est] = (await tx.execute<{ e: number }>(
sql`SELECT greatest(reltuples, 0)::float8 AS e FROM pg_class WHERE oid = ${t.oid}`
)) as unknown as { e: number }[];
if (!filtered && (t.relkind === 'r' || t.relkind === 'p') && est && est.e > MAX_EXACT) {
total = Math.round(est.e);
approximate = true;
} else {
await tx.execute(sql`SAVEPOINT c`);
try {
await tx.execute(sql`SET LOCAL statement_timeout = '5s'`);
const [c] = (await tx.execute<{ n: number }>(
sql`SELECT count(*)::float8 AS n FROM ${rel(t.schema, t.name)} t ${where}`
)) as unknown as { n: number }[];
total = Number(c.n);
} catch {
await tx.execute(sql`ROLLBACK TO SAVEPOINT c`);
}
}
const visible = rows.map(({ r }) => {
const out: Record<string, unknown> = {};
for (const c of t.columns) out[c.name] = r[c.name] ?? null;
return out;
});
const fkLabels: RowsResult['fkLabels'] = {};
for (const c of t.columns) {
if (!c.fk) continue;
const values = [...new Set(visible.map((r) => r[c.name]).filter((v) => v !== null))].map(
String
);
if (!values.length) continue;
const labels = await fkOptions(c, { values }, tx).catch(() => []);
if (labels.length)
fkLabels[c.name] = Object.fromEntries(labels.map((o) => [o.value, o.label]));
}
return { rows: visible, total, approximate, fkLabels };
});
}
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
type Exec = Pick<Tx, 'execute'>;
/** Rows of the table a foreign key points at, as {value, label} for a dropdown. */
export async function fkOptions(
c: ColumnInfo,
opts: { q?: string; values?: string[] },
runner: Exec = db
): Promise<{ value: string; label: string }[]> {
if (!c.fk) return [];
const target = await getTable(c.fk.schema, c.fk.table);
if (!target) return [];
const key = target.columns.find((x) => x.name === c.fk!.column);
if (!key) return [];
const labelCol =
['name', 'display_name', 'title', 'label', 'slug', 'email', 'code']
.map((n) => target.columns.find((x) => x.name === n))
.find(Boolean) ?? key;
const k = sql`t.${sql.identifier(key.name)}`;
const l = sql`t.${sql.identifier(labelCol.name)}`;
const conds: SQL[] = [];
if (opts.values?.length)
conds.push(
sql`${k}::text IN (${sql.join(
opts.values.slice(0, 500).map((v) => sql`${v}`),
sql`, `
)})`
);
if (opts.q?.trim()) {
const like = `%${opts.q.trim().replace(/[\\%_]/g, '\\$&')}%`;
conds.push(sql`(${l}::text ILIKE ${like} OR ${k}::text ILIKE ${like})`);
}
const rows = (await runner.execute<{ value: string; label: string }>(sql`
SELECT ${k}::text AS value, ${l}::text AS label FROM ${rel(target.schema, target.name)} t
${conds.length ? sql`WHERE ${sql.join(conds, sql` AND `)}` : sql``}
ORDER BY 2 NULLS LAST LIMIT ${opts.values?.length ? 500 : 50}`)) as unknown as {
value: string;
label: string;
}[];
return rows.map((r) => ({
value: r.value,
label: labelCol === key ? r.value : `${r.label ?? '—'} (${r.value})`
}));
}
/** All matching rows for CSV export (capped). */
export async function exportRows(t: TableInfo, q: RowQuery, cap = 200_000) {
return db.transaction(async (tx) => {
await tx.execute(sql`SET TRANSACTION READ ONLY`);
await tx.execute(sql`SET LOCAL statement_timeout = '60s'`);
const cols = t.columns.map((c) =>
c.kind === 'json' || c.kind === 'array'
? sql`to_json(t.${sql.identifier(c.name)})::text`
: sql`t.${sql.identifier(c.name)}::text`
);
const rows = (await tx.execute<{ v: (string | null)[] }>(
sql`SELECT to_jsonb(ARRAY[${sql.join(cols, sql`, `)}]::text[]) AS v FROM ${rel(t.schema, t.name)} t ${whereClause(t, q)} ${orderClause(t, q)} LIMIT ${cap}`
)) as unknown as { v: (string | null)[] }[];
return rows.map((r) => r.v);
});
}
// ---------- writing ----------
export class DataError extends Error {
constructor(
public status: number,
message: string
) {
super(message);
}
}
export type Actor = { id: string; email: string };
export type Pk = Record<string, unknown>;
function assertWritable(t: TableInfo, unlocked: boolean, need: 'insert' | 'update' | 'delete') {
if (t.access === 'locked') throw new DataError(403, 'This table is read-only here.');
if (!writeAllowed(t.access, unlocked))
throw new DataError(423, 'This is an app table: unlock editing first.');
const ok = need === 'insert' ? t.canInsert : need === 'update' ? t.canUpdate : t.canDelete;
if (!ok) throw new DataError(403, `The app has no ${need} permission on this table.`);
}
function assignments(t: TableInfo, values: Record<string, unknown>) {
const out: { c: ColumnInfo; v: string | null }[] = [];
for (const [name, raw] of Object.entries(values)) {
const c = t.columns.find((x) => x.name === name);
if (!c) throw new DataError(400, `Unknown column ${name}.`);
if (c.readOnly) throw new DataError(400, `${c.label} is generated and cannot be set.`);
const v = coerce(c.kind, raw, c.label);
if (!v.ok) throw new DataError(400, v.error);
if (v.value === null && !c.nullable && !(c.hasDefault && raw === undefined))
throw new DataError(400, `${c.label} is required.`);
if (
c.kind === 'enum' &&
v.value !== null &&
c.enumValues.length &&
!c.enumValues.includes(v.value)
)
throw new DataError(400, `${c.label} must be one of ${c.enumValues.join(', ')}.`);
out.push({ c, v: v.value });
}
return out;
}
function pkWhere(t: TableInfo, pk: Pk) {
if (!t.primaryKey.length) throw new DataError(400, 'This table has no primary key.');
return sql.join(
t.primaryKey.map((k) => {
const c = t.columns.find((x) => x.name === k)!;
const raw = pk[k];
if (raw === undefined || raw === null) throw new DataError(400, `Missing key ${k}.`);
const v = coerce(
c.kind,
typeof raw === 'object' ? JSON.stringify(raw) : String(raw),
c.label
);
if (!v.ok || v.value === null) throw new DataError(400, `Invalid key ${k}.`);
return sql`t.${sql.identifier(k)} = ${cast(v.value, c)}`;
}),
sql` AND `
);
}
let auditAvailable: boolean | null = null;
async function audit(
tx: Exec,
actor: Actor,
t: TableInfo,
action: 'insert' | 'update' | 'delete' | 'import' | 'add_column',
pk: unknown,
before: unknown,
after: unknown
) {
if (auditAvailable === null) {
const [r] = (await tx.execute<{ ok: boolean }>(
sql`SELECT to_regclass('meta.edit_log') IS NOT NULL AND has_table_privilege('meta.edit_log', 'INSERT') AS ok`
)) as unknown as { ok: boolean }[];
auditAvailable = Boolean(r?.ok);
}
if (!auditAvailable) return;
await tx.execute(sql`
INSERT INTO meta.edit_log (user_id, user_email, schema_name, table_name, action, pk, before, after)
VALUES (${actor.id}, ${actor.email}, ${t.schema}, ${t.name}, ${action},
${pk === null ? null : JSON.stringify(pk)}::jsonb,
${before === null ? null : JSON.stringify(before)}::jsonb,
${after === null ? null : JSON.stringify(after)}::jsonb)`);
}
function pgMessage(e: unknown): DataError {
if (e instanceof DataError) return e;
const err = (e as { cause?: unknown })?.cause ?? e;
const code = (err as { code?: string })?.code ?? '';
const msg = (err as { message?: string })?.message ?? 'Database error';
// constraint / data errors are the user's input; show Postgres' explanation
if (/^(22|23)/.test(code)) return new DataError(400, msg);
if (code === '42501') return new DataError(403, 'The app has no permission for this change.');
console.error('data editor write failed', e);
return new DataError(500, 'The database refused the change.');
}
async function write<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
try {
return await db.transaction(async (tx) => {
await tx.execute(sql`SET LOCAL statement_timeout = '30s'`);
return fn(tx);
});
} catch (e) {
throw pgMessage(e);
}
}
const one = <T>(rows: unknown) => (rows as T[])[0];
export async function insertRow(
t: TableInfo,
values: Record<string, unknown>,
unlocked: boolean,
actor: Actor
) {
assertWritable(t, unlocked, 'insert');
const set = assignments(t, values);
return write(async (tx) => {
const row = one<{ r: Record<string, unknown> }>(
await tx.execute(
set.length
? sql`INSERT INTO ${rel(t.schema, t.name)} AS t (${sql.join(
set.map((s) => sql.identifier(s.c.name)),
sql`, `
)})
VALUES (${sql.join(
set.map((s) => cast(s.v, s.c)),
sql`, `
)}) RETURNING ${rowJson(t)} AS r`
: sql`INSERT INTO ${rel(t.schema, t.name)} AS t DEFAULT VALUES RETURNING ${rowJson(t)} AS r`
)
).r;
const pk = Object.fromEntries(t.primaryKey.map((k) => [k, row[k]]));
await audit(tx, actor, t, 'insert', pk, null, row);
return row;
});
}
export async function updateRow(
t: TableInfo,
pk: Pk,
values: Record<string, unknown>,
unlocked: boolean,
actor: Actor
) {
assertWritable(t, unlocked, 'update');
const set = assignments(t, values);
if (!set.length) throw new DataError(400, 'Nothing to change.');
const where = pkWhere(t, pk);
return write(async (tx) => {
const before = one<{ r: Record<string, unknown> }>(
await tx.execute(
sql`SELECT ${rowJson(t)} AS r FROM ${rel(t.schema, t.name)} t WHERE ${where} FOR UPDATE`
)
)?.r;
if (!before) throw new DataError(404, 'That row no longer exists.');
const after = one<{ r: Record<string, unknown> }>(
await tx.execute(sql`UPDATE ${rel(t.schema, t.name)} AS t
SET ${sql.join(
set.map((s) => sql`${sql.identifier(s.c.name)} = ${cast(s.v, s.c)}`),
sql`, `
)}
WHERE ${where} RETURNING ${rowJson(t)} AS r`)
).r;
const changed = Object.fromEntries(set.map((s) => [s.c.name, before[s.c.name]]));
await audit(
tx,
actor,
t,
'update',
pk,
changed,
Object.fromEntries(set.map((s) => [s.c.name, after[s.c.name]]))
);
return after;
});
}
export async function deleteRows(t: TableInfo, pks: Pk[], unlocked: boolean, actor: Actor) {
assertWritable(t, unlocked, 'delete');
if (!pks.length || pks.length > 500) throw new DataError(400, 'Select 1–500 rows to delete.');
const wheres = pks.map((pk) => pkWhere(t, pk));
return write(async (tx) => {
let n = 0;
for (let i = 0; i < wheres.length; i++) {
const before = one<{ r: Record<string, unknown> }>(
await tx.execute(
sql`DELETE FROM ${rel(t.schema, t.name)} AS t WHERE ${wheres[i]} RETURNING ${rowJson(t)} AS r`
)
)?.r;
if (!before) continue;
n++;
await audit(tx, actor, t, 'delete', pks[i], before, null);
}
return n;
});
}
/** Append CSV rows (header = column names) in one transaction: all or nothing. */
export async function importRows(
t: TableInfo,
table: string[][],
unlocked: boolean,
actor: Actor
): Promise<number> {
assertWritable(t, unlocked, 'insert');
const [header, ...body] = table;
if (!header?.length) throw new DataError(400, 'The CSV is empty.');
if (body.length > 20_000) throw new DataError(400, 'Import at most 20,000 rows at a time.');
const names = header.map((h) => h.trim());
const unknown = names.filter((n) => n && !t.columns.some((c) => c.name === n));
if (unknown.length) throw new DataError(400, `Unknown column(s): ${unknown.join(', ')}.`);
if (new Set(names).size !== names.length)
throw new DataError(400, 'Duplicate column in the header.');
const prepared = body.map((cells, i) => {
if (cells.length !== names.length)
throw new DataError(400, `Row ${i + 2} has ${cells.length} cells, expected ${names.length}.`);
const values: Record<string, unknown> = {};
names.forEach((n, j) => {
if (!n) return;
const c = t.columns.find((x) => x.name === n)!;
if (c.readOnly) return;
// empty cell: column default when it has one, NULL otherwise (text stays '')
if (cells[j] === '' && (c.hasDefault || c.kind !== 'text')) return;
values[n] = cells[j];
});
try {
return assignments(t, values);
} catch (e) {
throw new DataError(400, `Row ${i + 2}: ${(e as Error).message}`);
}
});
return write(async (tx) => {
for (const set of prepared)
await tx.execute(
set.length
? sql`INSERT INTO ${rel(t.schema, t.name)} (${sql.join(
set.map((s) => sql.identifier(s.c.name)),
sql`, `
)})
VALUES (${sql.join(
set.map((s) => cast(s.v, s.c)),
sql`, `
)})`
: sql`INSERT INTO ${rel(t.schema, t.name)} DEFAULT VALUES`
);
await audit(tx, actor, t, 'import', null, null, { rows: prepared.length, columns: names });
return prepared.length;
});
}
export const LIST_UI_TYPES = [
'text',
'longtext',
'url',
'image',
'number',
'cents',
'date',
'datetime',
'bool',
'select',
'multiselect',
'json'
] as const;
/** Add a typed column to a runtime list (lists.* only) through meta.add_column(). */
export async function addListColumn(
t: TableInfo,
input: { name: string; uiType: string; label?: string; options?: string[] },
actor: Actor
) {
if (!t.isList || !t.listSlug || t.access !== 'open')
throw new DataError(403, 'Columns can only be added to lists.');
if (!/^[a-z][a-z0-9_]{0,62}$/.test(input.name))
throw new DataError(
400,
'Column names use lowercase letters, digits and _ and start with a letter.'
);
if (!(LIST_UI_TYPES as readonly string[]).includes(input.uiType))
throw new DataError(400, 'Unknown column type.');
if (t.columns.some((c) => c.name === input.name))
throw new DataError(409, 'That column already exists.');
const col: Record<string, unknown> = { name: input.name, ui_type: input.uiType };
if (input.label?.trim()) col.label = input.label.trim().slice(0, 100);
if (input.uiType === 'select' || input.uiType === 'multiselect') {
const values = (input.options ?? [])
.map((o) => o.trim())
.filter(Boolean)
.slice(0, 100);
if (input.uiType === 'select' && !values.length)
throw new DataError(400, 'Give the select at least one option.');
col.options = { values };
}
await write(async (tx) => {
await tx.execute(sql`SELECT meta.add_column(${t.listSlug}, ${JSON.stringify(col)}::jsonb)`);
await audit(tx, actor, t, 'add_column', null, null, col);
});
}

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import {
coerce,
hiddenColumn,
parseCsv,
parseFilter,
pgArrayLiteral,
plausibleIdent,
safeTypeName,
tableAccess,
toCsv,
typeKind,
writeAllowed,
type TableFacts
} from './data-policy';
const t = (schema: string, name: string, extra: Partial<TableFacts> = {}): TableFacts => ({
schema,
name,
relkind: 'r',
hasPrimaryKey: true,
canInsert: true,
canUpdate: true,
canDelete: true,
...extra
});
describe('tableAccess', () => {
it('hides auth secrets, migrations and credential tables', () => {
for (const name of ['session', 'account', 'verification', 'jwks'])
expect(tableAccess(t('auth', name))).toBe('hidden');
expect(tableAccess(t('drizzle', '__drizzle_migrations'))).toBe('hidden');
expect(tableAccess(t('money', 'bank_sessions'))).toBe('hidden');
expect(tableAccess(t('money', 'api_keys'))).toBe('hidden');
expect(tableAccess(t('public', 'anything'))).toBe('hidden');
});
it('keeps auth.user, meta and legacy read-only', () => {
expect(tableAccess(t('auth', 'user'))).toBe('locked');
expect(tableAccess(t('meta', 'tables'))).toBe('locked');
expect(tableAccess(t('legacy', 'lists'))).toBe('locked');
});
it('locks views, tables without a key or without write grants', () => {
expect(tableAccess(t('energy', 'gas_storage_latest', { relkind: 'v' }))).toBe('locked');
expect(tableAccess(t('money', 'x', { hasPrimaryKey: false }))).toBe('locked');
expect(
tableAccess(t('ref', 'pokemon', { canInsert: false, canUpdate: false, canDelete: false }))
).toBe('locked');
});
it('lists are open, app tables are core (unlock first)', () => {
expect(tableAccess(t('lists', 'books'))).toBe('open');
expect(tableAccess(t('money', 'transactions'))).toBe('core');
expect(tableAccess(t('books', 'sessions'))).toBe('core');
expect(writeAllowed('open', false)).toBe(true);
expect(writeAllowed('core', false)).toBe(false);
expect(writeAllowed('core', true)).toBe(true);
expect(writeAllowed('locked', true)).toBe(false);
expect(writeAllowed('hidden', true)).toBe(false);
});
});
describe('columns and identifiers', () => {
it('hides credential-like columns only', () => {
for (const c of [
'password',
'access_token',
'refresh_token_expires_at',
'api_key',
'client_secret'
])
expect(hiddenColumn(c)).toBe(true);
for (const c of ['key', 'session_id', 'iban', 'author', 'market_hash_name', 'r2_key'])
expect(hiddenColumn(c)).toBe(false);
});
it('validates identifiers and type names', () => {
expect(plausibleIdent('done_at')).toBe(true);
expect(plausibleIdent('a"; drop table x; --')).toBe(false);
expect(plausibleIdent('')).toBe(false);
expect(safeTypeName('timestamp with time zone')).toBe(true);
expect(safeTypeName('numeric(12,2)')).toBe(true);
expect(safeTypeName('"books"."metadata_status"')).toBe(true);
expect(safeTypeName('text); drop table x; --')).toBe(false);
});
it('maps types to editor kinds', () => {
expect(typeKind('integer', false)).toBe('number');
expect(typeKind('numeric(12,2)', false)).toBe('number');
expect(typeKind('text[]', false)).toBe('array');
expect(typeKind('books.metadata_status', true)).toBe('enum');
expect(typeKind('timestamp with time zone', false)).toBe('datetime');
expect(typeKind('jsonb', false)).toBe('json');
});
});
describe('coerce', () => {
it('validates per kind', () => {
expect(coerce('number', '12.5', 'n')).toEqual({ ok: true, value: '12.5' });
expect(coerce('number', 'abc', 'n').ok).toBe(false);
expect(coerce('number', '', 'n')).toEqual({ ok: true, value: null });
expect(coerce('bool', 'yes', 'b')).toEqual({ ok: true, value: 'true' });
expect(coerce('bool', 'maybe', 'b').ok).toBe(false);
expect(coerce('date', '2026-10-24', 'd').ok).toBe(true);
expect(coerce('date', '24/10', 'd').ok).toBe(false);
expect(coerce('json', '{"a":1}', 'j').ok).toBe(true);
expect(coerce('json', '{a:1}', 'j').ok).toBe(false);
expect(coerce('text', '', 't')).toEqual({ ok: true, value: '' });
expect(coerce('text', null, 't')).toEqual({ ok: true, value: null });
});
it('builds array literals', () => {
expect(pgArrayLiteral(['a', 'b c', 'x"y'])).toBe('{"a","b c","x\\"y"}');
expect(coerce('array', '["a","b"]', 'a')).toEqual({ ok: true, value: '{"a","b"}' });
expect(coerce('array', 'a, b', 'a')).toEqual({ ok: true, value: '{"a","b"}' });
});
});
describe('filters and csv', () => {
it('parses filters', () => {
expect(parseFilter('name:contains:foo:bar')).toEqual({
column: 'name',
op: 'contains',
value: 'foo:bar'
});
expect(parseFilter('done_at:null')).toEqual({ column: 'done_at', op: 'null', value: '' });
expect(parseFilter('x;y:eq:1')).toBeNull();
expect(parseFilter('name:like:1')).toBeNull();
});
it('round-trips CSV', () => {
const csv = toCsv(
['a', 'b'],
[
['x, y', 'he said "hi"'],
[null, { k: 1 }],
['line\nbreak', 3]
]
);
expect(parseCsv(csv)).toEqual([
['a', 'b'],
['x, y', 'he said "hi"'],
['', '{"k":1}'],
['line\nbreak', '3']
]);
expect(parseCsv('\ufeffa,b\r\n1,2')).toEqual([
['a', 'b'],
['1', '2']
]);
expect(() => parseCsv('a\n"open')).toThrow();
});
});

View File

@@ -0,0 +1,244 @@
// Pure rules for the owner-only database browser at /lists/db: which tables and columns
// are visible or editable, identifier checks, value coercion and CSV. No DB access here,
// so it is unit tested directly (data-policy.test.ts).
/** hidden: never listed or served. locked: always read-only. core: read-only until the
* owner unlocks editing for the table. open: freely editable (runtime lists). */
export type Access = 'hidden' | 'locked' | 'core' | 'open';
export type TableFacts = {
schema: string;
name: string;
/** pg_class.relkind: r table, p partitioned, v view, m materialized view */
relkind: string;
hasPrimaryKey: boolean;
canInsert: boolean;
canUpdate: boolean;
canDelete: boolean;
};
/** Schemas that are never shown: migration bookkeeping and Postgres internals. */
const HIDDEN_SCHEMAS = new Set(['drizzle', 'information_schema', 'public']);
/** Better Auth: only the user table is visible (read-only); sessions, accounts (OAuth
* tokens, password hashes) and verification codes are hidden. */
const AUTH_VISIBLE = new Set(['user']);
/** Individually hidden tables holding third-party credentials. */
const HIDDEN_TABLES = new Set(['money.bank_sessions']);
/** Schemas whose tables are always read-only here (registry, rollback copies, auth). */
const LOCKED_SCHEMAS = new Set(['meta', 'legacy', 'auth']);
const CREDENTIAL_NAME =
/(^|_)(passwords?|passwd|secrets?|tokens?|api_?keys?|private_?keys?|credentials?|salt|jwks|otp)($|_)/i;
export function tableAccess(t: TableFacts): Access {
if (HIDDEN_SCHEMAS.has(t.schema) || t.schema.startsWith('pg_')) return 'hidden';
if (t.schema === 'auth' && !AUTH_VISIBLE.has(t.name)) return 'hidden';
if (HIDDEN_TABLES.has(`${t.schema}.${t.name}`) || CREDENTIAL_NAME.test(t.name)) return 'hidden';
if (LOCKED_SCHEMAS.has(t.schema)) return 'locked';
if (t.relkind !== 'r' && t.relkind !== 'p') return 'locked';
if (!t.hasPrimaryKey) return 'locked';
if (!t.canInsert && !t.canUpdate && !t.canDelete) return 'locked';
return t.schema === 'lists' ? 'open' : 'core';
}
/** Columns that are never sent to the browser (tokens, password hashes, ...). */
export function hiddenColumn(name: string): boolean {
return CREDENTIAL_NAME.test(name);
}
/** Whether a write may go ahead: open tables always, core tables only when unlocked. */
export function writeAllowed(access: Access, unlocked: boolean): boolean {
return access === 'open' || (access === 'core' && unlocked);
}
/** Identifiers coming from the URL/body are looked up in the catalog; this is a cheap
* pre-filter so obviously bad input never reaches a query at all. */
export function plausibleIdent(s: unknown): s is string {
return (
typeof s === 'string' && s.length > 0 && s.length <= 63 && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(s)
);
}
/** format_type() output that is safe to splice into a CAST (it comes from pg_catalog). */
export function safeTypeName(type: string): boolean {
return /^[A-Za-z0-9_ ."(),[\]]{1,120}$/.test(type) && !type.includes('--') && !type.includes(';');
}
export type Kind =
| 'text'
| 'number'
| 'bool'
| 'date'
| 'datetime'
| 'time'
| 'json'
| 'enum'
| 'array'
| 'uuid'
| 'other';
export function typeKind(type: string, isEnum: boolean): Kind {
if (type.endsWith('[]')) return 'array';
if (isEnum) return 'enum';
if (/^(smallint|integer|bigint|real|double precision|numeric|money)/.test(type)) return 'number';
if (type === 'boolean') return 'bool';
if (type === 'date') return 'date';
if (type.startsWith('timestamp')) return 'datetime';
if (type.startsWith('time')) return 'time';
if (type === 'json' || type === 'jsonb') return 'json';
if (type === 'uuid') return 'uuid';
if (/^(text|character|"char"|citext|name)/.test(type)) return 'text';
return 'other';
}
/** Postgres array literal for a list of strings, e.g. ['a', 'b c'] -> {"a","b c"}. */
export function pgArrayLiteral(items: unknown[]): string {
return `{${items
.map((v) =>
v === null || v === undefined
? 'NULL'
: `"${String(typeof v === 'object' ? JSON.stringify(v) : v).replace(/["\\]/g, '\\$&')}"`
)
.join(',')}}`;
}
export type Coerced = { ok: true; value: string | null } | { ok: false; error: string };
/**
* Turn a value from the editor/CSV into the text that is bound as a parameter and
* CAST to the column type by Postgres (which does the final validation). null = NULL.
* Empty input means NULL for every non-text column.
*/
export function coerce(kind: Kind, raw: unknown, label: string): Coerced {
if (raw === null || raw === undefined) return { ok: true, value: null };
if (kind === 'array') {
let items: unknown = raw;
if (typeof raw === 'string') {
const s = raw.trim();
if (!s) return { ok: true, value: null };
if (s.startsWith('{')) return { ok: true, value: s }; // already a Postgres literal
try {
items = JSON.parse(s);
} catch {
items = s
.split(',')
.map((x) => x.trim())
.filter(Boolean);
}
}
if (!Array.isArray(items)) return { ok: false, error: `${label} must be a list.` };
return { ok: true, value: pgArrayLiteral(items) };
}
if (kind === 'json') {
if (typeof raw !== 'string') return { ok: true, value: JSON.stringify(raw) };
if (!raw.trim()) return { ok: true, value: null };
try {
JSON.parse(raw);
} catch {
return { ok: false, error: `${label} is not valid JSON.` };
}
return { ok: true, value: raw };
}
const s = typeof raw === 'string' ? raw : String(raw);
if (kind === 'text' || kind === 'other') return { ok: true, value: s };
const t = s.trim();
if (!t) return { ok: true, value: null };
switch (kind) {
case 'number':
if (!/^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(t.replace(/_/g, '')))
return { ok: false, error: `${label} must be a number.` };
return { ok: true, value: t.replace(/_/g, '') };
case 'bool': {
const v = t.toLowerCase();
if (['true', 't', 'yes', 'y', '1', 'on'].includes(v)) return { ok: true, value: 'true' };
if (['false', 'f', 'no', 'n', '0', 'off'].includes(v)) return { ok: true, value: 'false' };
return { ok: false, error: `${label} must be true or false.` };
}
case 'date':
if (!/^\d{4}-\d{2}-\d{2}/.test(t))
return { ok: false, error: `${label} must be a date (YYYY-MM-DD).` };
return { ok: true, value: t };
case 'datetime':
if (Number.isNaN(Date.parse(t)) && !/^\d{4}-\d{2}-\d{2}/.test(t))
return { ok: false, error: `${label} must be a date and time.` };
return { ok: true, value: t };
case 'uuid':
if (!/^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i.test(t))
return { ok: false, error: `${label} must be a UUID.` };
return { ok: true, value: t };
default:
return { ok: true, value: t };
}
}
/** Filter operators accepted by the grid (?f=<column>:<op>:<value>). */
export const FILTER_OPS = [
'contains',
'eq',
'ne',
'gt',
'gte',
'lt',
'lte',
'null',
'notnull'
] as const;
export type FilterOp = (typeof FILTER_OPS)[number];
export type Filter = { column: string; op: FilterOp; value: string };
export function parseFilter(s: string): Filter | null {
const i = s.indexOf(':');
const j = i < 0 ? -1 : s.indexOf(':', i + 1);
if (i < 1) return null;
const column = s.slice(0, i);
const op = (j < 0 ? s.slice(i + 1) : s.slice(i + 1, j)) as FilterOp;
const value = j < 0 ? '' : s.slice(j + 1);
if (!plausibleIdent(column) || !FILTER_OPS.includes(op) || value.length > 500) return null;
return { column, op, value };
}
// ---- CSV (RFC 4180) ----
export function csvCell(v: unknown): string {
if (v === null || v === undefined) return '';
const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
return /[",\r\n]/.test(s) || /^\s|\s$/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
export function toCsv(header: string[], rows: unknown[][]): string {
return [header, ...rows].map((r) => r.map(csvCell).join(',')).join('\r\n') + '\r\n';
}
export function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = '';
let quoted = false;
let i = text.charCodeAt(0) === 0xfeff ? 1 : 0; // BOM
for (; i < text.length; i++) {
const ch = text[i];
if (quoted) {
if (ch === '"') {
if (text[i + 1] === '"') {
cell += '"';
i++;
} else quoted = false;
} else cell += ch;
} else if (ch === '"' && cell === '') quoted = true;
else if (ch === ',') {
row.push(cell);
cell = '';
} else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++;
row.push(cell);
rows.push(row);
row = [];
cell = '';
} else cell += ch;
}
if (quoted) throw new Error('CSV has an unterminated quoted field.');
if (cell !== '' || row.length) {
row.push(cell);
rows.push(row);
}
return rows.filter((r) => !(r.length === 1 && r[0] === ''));
}

View File

@@ -0,0 +1,50 @@
// Request helpers shared by the /lists/db routes. Every route calls ownerTable() (or
// requireOwner) itself, so access never depends on the layout load having run.
import { error, json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { parseFilter, type Filter } from '#lib/server/data-policy.js';
import { DataError, getTable, type Actor, type RowQuery } from '#lib/server/data-editor.js';
type Event = { locals: App.Locals; params: Record<string, string>; request: Request; url: URL };
export async function ownerTable(event: Event) {
const user = requireOwner(event);
const table = await getTable(event.params.schema ?? '', event.params.table ?? '');
if (!table) error(404, 'Not Found');
const actor: Actor = { id: user.id, email: user.email };
return { table, actor };
}
/** Core (app) tables are read-only until the owner unlocks editing in the UI, which sends
* this header with every write; the server refuses core-table writes without it. */
export const unlocked = (request: Request) => request.headers.get('x-data-unlock') === '1';
export function rowQuery(url: URL): RowQuery {
const p = url.searchParams;
const filters = p
.getAll('f')
.slice(0, 20)
.map(parseFilter)
.filter((f): f is Filter => f !== null);
const limit = Number(p.get('limit') ?? 50);
const page = Number(p.get('page') ?? 1);
const safeLimit = [25, 50, 100, 250, 500].includes(limit) ? limit : 50;
return {
q: (p.get('q') ?? '').slice(0, 200),
filters,
sort: p.get('sort') || undefined,
dir: p.get('dir') === 'desc' ? 'desc' : 'asc',
limit: safeLimit,
offset: Number.isSafeInteger(page) && page > 1 ? (page - 1) * safeLimit : 0
};
}
export function failure(e: unknown) {
if (e instanceof DataError) return json({ message: e.message }, { status: e.status });
throw e;
}
export const noStore = {
'cache-control': 'private, no-store',
'x-robots-tag': 'noindex, nofollow'
};

View File

@@ -14,3 +14,4 @@ export * from './cs-prices';
export * from './house-tracker';
export * from './health';
export * from './books';
export * from './tasks';

View File

@@ -1,4 +1,5 @@
import {
bigint,
boolean,
check,
index,
@@ -88,5 +89,29 @@ export const metaColumns = metaSchema.table(
]
);
/** Audit trail of every write made through the database browser (/lists/db). */
export const metaEditLog = metaSchema.table(
'edit_log',
{
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
at: ts('at').notNull().defaultNow(),
userId: text('user_id'),
userEmail: text('user_email'),
schemaName: text('schema_name').notNull(),
tableName: text('table_name').notNull(),
action: text('action').notNull(),
pk: jsonb('pk'),
before: jsonb('before'),
after: jsonb('after')
},
(t) => [
index('edit_log_table_at_index').on(t.schemaName, t.tableName, t.at),
check(
'edit_log_action_check',
sql`action IN ('insert','update','delete','import','add_column')`
)
]
);
export type MetaTable = typeof metaTables.$inferSelect;
export type MetaColumn = typeof metaColumns.$inferSelect;

View File

@@ -24,6 +24,9 @@ export const moneyAccounts = moneySchema.table(
name: text('name').notNull(),
openingBalanceCents: integer('opening_balance_cents').notNull().default(0),
kind: text('kind').notNull().default('spendable'),
// False for money tracked but not owned (e.g. a savings account parents controlled):
// still listed with its own balance, left out of net worth and the total wealth chart.
includeInTotal: boolean('include_in_total').notNull().default(true),
color: text('color'),
// Household scope: Stan's own money vs. the joint account with Juhee.
scope: text('scope').notNull().default('personal'),

View File

@@ -14,3 +14,4 @@ export const siteSchema = pgSchema('site');
export const metaSchema = pgSchema('meta');
export const healthSchema = pgSchema('health');
export const booksSchema = pgSchema('books');
export const tasksSchema = pgSchema('tasks');

View File

@@ -0,0 +1,145 @@
import { sql } from 'drizzle-orm';
import {
type AnyPgColumn,
boolean,
check,
date,
index,
integer,
numeric,
smallint,
text,
time,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { tasksSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// Private task + goal tracker (owner only, see requireOwner); meant to replace TickTick.
// No user_id: there is exactly one owner, and the schema is granted to personal_app only.
// Statuses are text + CHECK (not enums) so new ones are a one-line migration.
// Due dates are Europe/Amsterdam wall-clock (date + optional time), like TickTick's
// all-day vs timed tasks; reminders are real moments (timestamptz).
export const TASK_STATUSES = ['todo', 'in_progress', 'done', 'dropped'] as const;
export type TaskStatus = (typeof TASK_STATUSES)[number];
export const GOAL_STATUSES = ['active', 'achieved', 'dropped'] as const;
export type GoalStatus = (typeof GOAL_STATUSES)[number];
/** Areas group tasks (TickTick "lists"); `slug` is the stable key for imports. */
export const todoAreas = tasksSchema.table(
'areas',
{
id: id(),
slug: text('slug').notNull(),
name: text('name').notNull(),
description: text('description'),
color: text('color'), // CSS colour, optional
sortOrder: integer('sort_order').notNull().default(0),
archivedAt: timestamptz('archived_at'),
...timestamps
},
(t) => [
uniqueIndex('areas_slug_key').on(t.slug),
check('areas_slug_check', sql`${t.slug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
check('areas_name_check', sql`length(btrim(${t.name})) > 0`)
]
);
/** Long-term goals, optionally with a target date and a numeric metric. */
export const todoGoals = tasksSchema.table(
'goals',
{
id: id(),
title: text('title').notNull(),
notes: text('notes'),
status: text('status').$type<GoalStatus>().notNull().default('active'),
areaId: integer('area_id').references(() => todoAreas.id, { onDelete: 'set null' }),
targetDate: date('target_date', { mode: 'string' }),
metric: text('metric'), // what is measured, e.g. "net income per month"
metricUnit: text('metric_unit'), // e.g. "EUR"
metricTarget: numeric('metric_target', { precision: 14, scale: 2, mode: 'number' }),
metricCurrent: numeric('metric_current', { precision: 14, scale: 2, mode: 'number' }),
flagged: boolean('flagged').notNull().default(false),
sortOrder: integer('sort_order').notNull().default(0),
source: text('source'), // e.g. 'daisy-import', 'manual', 'ticktick'
achievedAt: timestamptz('achieved_at'),
...timestamps
},
(t) => [
uniqueIndex('goals_title_key').on(sql`lower(${t.title})`),
check('goals_title_check', sql`length(btrim(${t.title})) > 0`),
check('goals_status_check', sql`${t.status} IN ('active','achieved','dropped')`)
]
);
export const todoTasks = tasksSchema.table(
'tasks',
{
id: id(),
title: text('title').notNull(),
notes: text('notes'),
status: text('status').$type<TaskStatus>().notNull().default('todo'),
priority: smallint('priority').notNull().default(0), // 0 none, 1 low, 2 medium, 3 high
flagged: boolean('flagged').notNull().default(false), // Stan's "(!)"
dueDate: date('due_date', { mode: 'string' }),
dueTime: time('due_time'), // optional; Europe/Amsterdam wall clock
reminderAt: timestamptz('reminder_at'),
recurrence: text('recurrence'), // RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO (not acted on yet)
areaId: integer('area_id').references(() => todoAreas.id, { onDelete: 'set null' }),
goalId: integer('goal_id').references(() => todoGoals.id, { onDelete: 'set null' }),
parentId: integer('parent_id').references((): AnyPgColumn => todoTasks.id, {
onDelete: 'cascade'
}),
tags: text('tags').array().notNull().default([]),
sortOrder: integer('sort_order').notNull().default(0),
source: text('source'), // e.g. 'daisy-import', 'manual', 'ticktick'
externalId: text('external_id'), // id in the source system, for future syncs
startAt: timestamptz('start_at'), // planned start (TickTick startDate)
timeZone: text('time_zone'), // zone the task was planned in (TickTick); due_time stays Amsterdam
startedAt: timestamptz('started_at'),
completedAt: timestamptz('completed_at'),
...timestamps
},
(t) => [
index('tasks_area_idx').on(t.areaId),
index('tasks_goal_idx').on(t.goalId),
index('tasks_parent_idx').on(t.parentId),
index('tasks_status_due_idx').on(t.status, t.dueDate),
index('tasks_tags_idx').using('gin', t.tags),
uniqueIndex('tasks_source_external_key')
.on(t.source, t.externalId)
.where(sql`${t.externalId} IS NOT NULL`),
check('tasks_title_check', sql`length(btrim(${t.title})) > 0`),
check('tasks_status_check', sql`${t.status} IN ('todo','in_progress','done','dropped')`),
check('tasks_priority_check', sql`${t.priority} BETWEEN 0 AND 3`),
check('tasks_parent_check', sql`${t.parentId} IS NULL OR ${t.parentId} <> ${t.id}`),
check('tasks_time_check', sql`${t.dueTime} IS NULL OR ${t.dueDate} IS NOT NULL`)
]
);
/** Append-only history per task (status changes, notes, progress updates). */
export const todoActivity = tasksSchema.table(
'activity',
{
id: id(),
taskId: integer('task_id').references(() => todoTasks.id, { onDelete: 'cascade' }),
goalId: integer('goal_id').references(() => todoGoals.id, { onDelete: 'cascade' }),
kind: text('kind').notNull(), // created | status | edit | note | progress
fromStatus: text('from_status'),
toStatus: text('to_status'),
note: text('note'),
value: numeric('value', { precision: 14, scale: 2, mode: 'number' }), // goal metric snapshot
createdAt: timestamptz('created_at').notNull().defaultNow()
},
(t) => [
index('activity_task_idx').on(t.taskId, t.createdAt),
index('activity_goal_idx').on(t.goalId, t.createdAt),
check('activity_target_check', sql`${t.taskId} IS NOT NULL OR ${t.goalId} IS NOT NULL`),
check('activity_kind_check', sql`${t.kind} IN ('created','status','edit','note','progress')`)
]
);
export type TodoArea = typeof todoAreas.$inferSelect;
export type TodoGoal = typeof todoGoals.$inferSelect;
export type TodoTask = typeof todoTasks.$inferSelect;

View File

@@ -172,6 +172,9 @@ export function createEnableBankingClient(config: EnableBankingConfig) {
country: string;
maximum_consent_validity: number;
psu_types: string[];
bic?: string;
beta?: boolean;
required_psu_headers?: string[];
}[];
}>('GET', `/aspsps?${new URLSearchParams({ country, psu_type: psuType, service: 'AIS' })}`),
startAuth: (input: {
@@ -211,6 +214,8 @@ export function createEnableBankingClient(config: EnableBankingConfig) {
dateFrom?: string;
dateTo?: string;
status?: 'BOOK' | 'PDNG';
/** 'longest' asks Enable Banking to find the longest period the bank serves. */
strategy?: 'default' | 'longest';
maxPages?: number;
} = {}
) {
@@ -221,6 +226,7 @@ export function createEnableBankingClient(config: EnableBankingConfig) {
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 (options.strategy) query.set('strategy', options.strategy);
if (continuationKey) query.set('continuation_key', continuationKey);
const result = await request<{
transactions: BankTransaction[];

View File

@@ -44,6 +44,7 @@ export type ListSummary = {
checked: number;
};
export type ListView = Omit<ListSummary, 'total' | 'checked'> & {
tableName: string;
columns: ListColumn[];
items: ListItem[];
};
@@ -144,6 +145,7 @@ export async function getList(slug: string): Promise<ListView | null> {
slug: t.slug,
pictureUrl: t.pictureUrl,
isProgress: t.hasDone,
tableName: t.tableName,
columns,
items: [...rows].map((r) => toItem(r, columns))
};

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
accountNumberKey,
cents,
csvDate,
dropInternalTransfers,
importCents,
parseCsv,
parseTransactionImport,
@@ -117,3 +119,150 @@ describe('revolut statements and import dedupe', () => {
expect(duplicates).toBe(2);
});
});
describe('knab exports', () => {
const header =
'Rekeningnummer;Transactiedatum;Valutacode;CreditDebet;Bedrag;Tegenrekeningnummer;Tegenrekeninghouder;Valutadatum;Betaalwijze;Omschrijving;Type betaling;Machtigingsnummer;Incassant ID;Referentie;Boekdatum;\n';
const line = (
account: string,
date: string,
cd: string,
amount: string,
other: string,
name: string,
method: string,
note: string,
ref: string
) =>
`"${account}";"${date}";"EUR";"${cd}";"${amount}";"${other}";"${name}";"${date}";"${method}";"${note}";"";"";"";"${ref}";"${date}";\n`;
const current =
header +
line(
'NL98KNAB0408449136',
'01-10-2026',
'Afschrijvingen',
'1',
'80000208',
'Knab',
'Pakketkosten',
'Pakketkosten',
'R1'
) +
line(
'NL98KNAB0408449136',
'02-10-2026',
'Bijschrijvingen',
'1250,5',
'30581727',
'Spaar',
'Ontvangen betaling',
'',
'SHARED'
) +
line(
'NL98KNAB0408449136',
'03-10-2026',
'Afschrijvingen',
'2,35',
'NL11INGB0000000001',
'NS',
'iDEAL',
'Treinkaartje',
'R3'
);
const savings =
header +
line(
'30581727',
'02-10-2026',
'Afschrijvingen',
'1250,5',
'NL98KNAB0408449136',
'S Runge',
'Overboeking',
'',
'SHARED'
) +
line('30581727', '30-09-2026', 'Bijschrijvingen', '0,42', '', '', 'Rente', 'Rente', 'R5');
it('parses signs, decimal commas and descriptions', () => {
const rows = parseTransactionImport(current, 'Knab');
expect(
rows.map((row) => [row.date, row.description, row.amountCents, row.counterparty])
).toEqual([
['2026-10-01', 'Knab: Pakketkosten', -100, 'Knab'],
['2026-10-02', 'Spaar', 125050, 'Spaar'],
['2026-10-03', 'NS: Treinkaartje', -235, 'NS']
]);
expect(parseTransactionImport(current)[0].account).toBe('NL98KNAB0408449136');
});
it('scopes ids to the booked account because Referentie repeats across accounts', () => {
const a = parseTransactionImport(current, 'Knab').find((row) => row.amountCents === 125050)!;
const b = parseTransactionImport(savings, 'Knab').find((row) => row.amountCents === -125050)!;
expect(a.externalId).toBe('knab:408449136:SHARED');
expect(b.externalId).toBe('knab:30581727:SHARED');
});
it('drops transfers between own accounts but keeps interest', () => {
const rows = [
...parseTransactionImport(current, 'Knab'),
...parseTransactionImport(savings, 'Knab')
];
const { kept, internal } = dropInternalTransfers(rows, ['NL98KNAB0408449136', '30581727']);
expect(internal.map((row) => row.amountCents)).toEqual([125050, -125050]);
expect(kept.map((row) => row.amountCents)).toEqual([-100, -235, 42]);
expect(accountNumberKey('NL98 KNAB 0408 4491 36')).toBe(accountNumberKey('408449136'));
});
it('rejects malformed amounts', () => {
const bad =
header +
line('NL98KNAB0408449136', '01-10-2026', 'Afschrijvingen', '1.234,00', '', '', '', '', 'X');
expect(() => parseTransactionImport(bad, 'Knab')).toThrow('Invalid data in Knab row 1');
});
});
describe('import reconcile', () => {
it('attaches hand-entered twins and replaces hand sums of several payments', () => {
const rows = [
{ accountId: 2, date: '2026-03-01', amountCents: -20000, externalId: 'k:1' },
{ accountId: 2, date: '2026-03-01', amountCents: -300, externalId: 'k:2' },
{ accountId: 2, date: '2026-03-02', amountCents: -600, externalId: 'k:3' },
{ accountId: 2, date: '2026-03-03', amountCents: 5000, externalId: 'k:4' }
];
const existing = [
{
id: 1,
accountId: 2,
date: '2026-03-01',
amountCents: -20900,
externalId: null,
description: 'To Joint'
},
{
id: 2,
accountId: 2,
date: '2026-03-03',
amountCents: 5000,
externalId: null,
description: 'From Revolut'
},
{
id: 3,
accountId: 2,
date: '2026-03-01',
amountCents: -20300,
externalId: null,
description: 'Start balance'
}
];
const { insert, duplicates, attach, replace } = planImport(rows, existing);
expect(duplicates).toBe(1);
expect(attach.map((item) => [item.id, item.row.externalId])).toEqual([[2, 'k:4']]);
expect(insert.map((row) => row.externalId)).toEqual(['k:1', 'k:2', 'k:3']);
expect(
replace.map((item) => [item.id, item.parts.map((row) => row.externalId).sort()])
).toEqual([[1, ['k:1', 'k:2', 'k:3']]]);
});
});

View File

@@ -1,5 +1,6 @@
import { fail } from '@sveltejs/kit';
import { createHash } from 'node:crypto';
import { findSumMatch } from './reconcile';
export const utilityKinds = ['electricity', 'gas', 'water'] as const;
export const cadences = ['weekly', 'monthly', 'yearly'] as const;
@@ -131,11 +132,48 @@ export type ImportedTransaction = {
account: string;
description: string;
amountCents: number;
/** Stable per-row id ("csv:<hash>") so importing the same file twice adds nothing. */
/** Stable per-row id ("csv:<hash>", "knab:<account>:<ref>") so re-imports add nothing. */
externalId: string;
counterparty?: string | null;
/** Bank account the row was booked on (bank statements that say so, e.g. Knab). */
sourceAccount?: string;
/** The other side's account number, used to recognise transfers between own accounts. */
counterpartyAccount?: string;
};
export type ImportFormat = 'tracker' | 'revolut';
export type ImportFormat = 'tracker' | 'revolut' | 'knab';
/**
* Canonical account number: Dutch IBANs and the bare numbers Knab uses for its savings
* accounts compare equal (NL98KNAB0408449136 ~ 408449136).
*/
export function accountNumberKey(value: string | null | undefined) {
const raw = (value ?? '').replace(/[\s.-]/g, '').toUpperCase();
const iban = /^[A-Z]{2}\d{2}[A-Z]{4}(\d{6,})$/.exec(raw);
return (iban ? iban[1] : raw).replace(/^0+/, '');
}
/** Knab "Exporteer transacties" CSV (semicolon separated, Dutch headers). */
export function isKnabHeader(header: string[]) {
return ['rekeningnummer', 'transactiedatum', 'creditdebet', 'bedrag', 'referentie'].every((key) =>
header.includes(key)
);
}
/** Drops transfers between the given own accounts (both legs), keeping everything else. */
export function dropInternalTransfers<T extends ImportedTransaction>(
rows: T[],
own: Iterable<string>
) {
const keys = new Set([...own].map(accountNumberKey).filter(Boolean));
const kept: T[] = [];
const internal: T[] = [];
for (const row of rows) {
const other = accountNumberKey(row.counterpartyAccount);
(other && keys.has(other) ? internal : kept).push(row);
}
return { kept, internal };
}
function headerOf(rows: string[][]) {
return rows[0].map((value) =>
@@ -178,6 +216,7 @@ export function parseTransactionImport(
const header = headerOf(rows);
rows.shift();
const seen = new Map<string, number>();
if (isKnabHeader(header)) return parseKnab(header, rows, defaultAccount, seen);
if (isRevolutHeader(header)) {
const account = defaultAccount?.trim();
if (!account) throw new Error('Choose which account this Revolut statement belongs to.');
@@ -249,14 +288,74 @@ export function parseTransactionImport(
return parsed.sort((a, b) => a.date.localeCompare(b.date));
}
function parseKnab(
header: string[],
rows: string[][],
defaultAccount: string | undefined,
seen: Map<string, number>
) {
const col = (key: string) => header.indexOf(key);
const cell = (row: string[], key: string) => (col(key) >= 0 ? (row[col(key)] ?? '').trim() : '');
const parsed: ImportedTransaction[] = [];
for (const [index, row] of rows.entries()) {
if (row.every((value) => !value.trim())) continue;
const sourceAccount = cell(row, 'rekeningnummer').replace(/\s/g, '').toUpperCase();
const currency = cell(row, 'valutacode').toUpperCase();
if (currency && currency !== 'EUR') continue;
const date = csvDate(cell(row, 'transactiedatum'));
// Knab writes plain amounts with a decimal comma and never a thousands separator.
const amount = /^\d{1,9}(?:,\d{1,2})?$/.test(cell(row, 'bedrag'))
? cents(cell(row, 'bedrag'))
: undefined;
const direction = cell(row, 'creditdebet').toLowerCase();
const debit = direction.startsWith('af') || direction === 'd' || direction === 'debet';
const credit = direction.startsWith('bij') || direction === 'c' || direction === 'credit';
if (!sourceAccount || !date || amount == null || (!debit && !credit))
throw new Error(`Invalid data in Knab row ${index + 1}. No rows were imported.`);
if (!amount) continue;
const counterparty = cell(row, 'tegenrekeninghouder');
const note = cell(row, 'omschrijving');
const method = cell(row, 'betaalwijze');
const description = (
counterparty && note && note.toLowerCase() !== counterparty.toLowerCase()
? `${counterparty}: ${note}`
: counterparty || note || method || 'Knab'
).slice(0, 500);
const reference = cell(row, 'referentie');
const account = defaultAccount?.trim() || sourceAccount;
// Referentie repeats across a customer's accounts (both legs of a transfer), so the
// id is scoped to the account the row was booked on.
const externalId = reference
? `knab:${accountNumberKey(sourceAccount)}:${reference}`
: importId(['knab', sourceAccount, date, amount, description], seen);
parsed.push({
date,
account,
description,
amountCents: debit ? -amount : amount,
externalId,
counterparty: counterparty || null,
sourceAccount,
counterpartyAccount: cell(row, 'tegenrekeningnummer') || undefined
});
}
if (!parsed.length) throw new Error('No EUR transactions found in this Knab export.');
return parsed.sort((a, b) => a.date.localeCompare(b.date));
}
export type ExistingImportRow = {
id: number;
accountId: number;
date: string;
amountCents: number;
externalId: string | null;
/** Hand-entered rows (no external id) can be replaced; opening balances never. */
description?: string;
};
const isOpeningBalanceRow = (description: string | undefined) =>
/^(start|opening) balance/i.test((description ?? '').trim());
function daysApart(a: string, b: string) {
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
}
@@ -264,7 +363,10 @@ function daysApart(a: string, b: string) {
/**
* 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).
* can only absorb one imported row). Hand-entered matches are returned in `attach` so the
* caller can give them the import id. A hand-entered row equal to the sum of 2-3 new rows
* within a day ("To Joint 209" = 200 + 3 + 6) is returned in `replace`: the new rows go in
* and the hand row should be deleted.
*/
export function planImport<
T extends { accountId: number; date: string; amountCents: number; externalId: string }
@@ -274,11 +376,13 @@ export function planImport<
);
const byKey = new Map<string, ExistingImportRow[]>();
for (const row of existing) {
if (!row.externalId && isOpeningBalanceRow(row.description)) continue;
const key = `${row.accountId}:${row.amountCents}`;
byKey.set(key, [...(byKey.get(key) ?? []), row]);
}
const claimed = new Set<number>();
const insert: T[] = [];
const attach: { id: number; row: T }[] = [];
let duplicates = 0;
for (const row of rows) {
const idKey = `${row.accountId}:${row.externalId}`;
@@ -292,10 +396,24 @@ export function planImport<
if (match) {
claimed.add(match.id);
duplicates++;
if (!match.externalId) attach.push({ id: match.id, row });
continue;
}
ids.add(idKey);
insert.push(row);
}
return { insert, duplicates };
const replace: { id: number; parts: T[] }[] = [];
const taken = new Set<number>();
for (const hand of existing) {
if (hand.externalId || claimed.has(hand.id) || isOpeningBalanceRow(hand.description)) continue;
const pool = insert.map((row) =>
row.accountId === hand.accountId ? row : { ...row, amountCents: 0 }
);
const parts = findSumMatch(hand, pool, taken);
if (!parts) continue;
parts.forEach((index) => taken.add(index));
claimed.add(hand.id);
replace.push({ id: hand.id, parts: parts.map((index) => insert[index]) });
}
return { insert, duplicates, attach, replace };
}

View File

@@ -16,6 +16,9 @@ describe('owner gate', () => {
expect(isOwnerOnlyPath('/trackers/Health/import')).toBe(true);
expect(isOwnerOnlyPath('/trackers/healthy')).toBe(false);
expect(isOwnerOnlyPath('/trackers')).toBe(false);
expect(isOwnerOnlyPath('/trackers/tasks')).toBe(true);
expect(isOwnerOnlyPath('/trackers/Tasks/')).toBe(true);
expect(isOwnerWritePath('/trackers/tasks')).toBe(true);
});
it('gates the book reader, files and APIs but not the catalogue', () => {

View File

@@ -19,7 +19,12 @@ export function requireOwner(event: OwnerEvent) {
}
/** Paths that only the owner may see (checked in hooks.server.ts before any route runs). */
export const ownerOnlyPrefixes = ['/trackers/health', '/trackers/books/files'] as const;
export const ownerOnlyPrefixes = [
'/trackers/health',
'/trackers/tasks',
'/trackers/books/files',
'/lists/db'
] as const;
/**
* Owner-only paths inside otherwise public areas: the book reader and its progress/notes
@@ -31,7 +36,11 @@ export const ownerOnlyPatterns = [/^\/trackers\/books\/\d+\/(read|progress|notes
* Areas where only the owner may write: any non-GET request from anyone else is refused
* (404) in hooks.server.ts, which also covers form actions on the public book pages.
*/
export const ownerWritePrefixes = ['/trackers/health', '/trackers/books'] as const;
export const ownerWritePrefixes = [
'/trackers/health',
'/trackers/tasks',
'/trackers/books'
] as const;
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
const under = (path: string, prefix: string) => path === prefix || path.startsWith(`${prefix}/`);

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { findSumMatch } from './reconcile';
describe('sum matching', () => {
const pool = [
{ date: '2026-03-01', amountCents: -20000 },
{ date: '2026-03-01', amountCents: -300 },
{ date: '2026-03-02', amountCents: -600 },
{ date: '2026-03-05', amountCents: -900 },
{ date: '2026-03-01', amountCents: 900 }
];
it('finds the payments a hand entry stands for', () => {
expect(
findSumMatch({ date: '2026-03-01', amountCents: -20900 }, pool, new Set())?.sort()
).toEqual([0, 1, 2]);
expect(findSumMatch({ date: '2026-03-01', amountCents: -20300 }, pool, new Set())).toEqual([
0, 1
]);
});
it('ignores used rows, other days and opposite signs', () => {
expect(
findSumMatch({ date: '2026-03-01', amountCents: -20900 }, pool, new Set([2]))
).toBeNull();
expect(findSumMatch({ date: '2026-03-05', amountCents: -1200 }, pool, new Set())).toBeNull();
expect(findSumMatch({ date: '2026-03-01', amountCents: -19100 }, pool, new Set())).toBeNull();
});
it('needs at least two parts', () => {
expect(findSumMatch({ date: '2026-03-01', amountCents: -300 }, pool, new Set())).toBeNull();
});
});

View File

@@ -0,0 +1,49 @@
// Shared reconcile helpers for bank sync and CSV import: a hand-entered row often stands
// for several bank payments (e.g. "To Joint 209" = 200 + 3 + 6 on the same day).
export type DatedAmount = { date: string; amountCents: number };
export function daysBetween(a: string, b: string) {
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
}
/**
* Finds 2..maxParts unused pool entries, each within ±maxDays of `target.date`, whose
* amounts add up exactly to `target.amountCents`. Prefers fewer parts, then the smallest
* total date distance. Returns pool indexes, or null. Every part must have the same sign as
* the target, which keeps coincidental matches rare.
*/
export function findSumMatch(
target: DatedAmount,
pool: DatedAmount[],
used: Set<number>,
maxParts = 3,
maxDays = 1
): number[] | null {
const near = pool
.map((row, index) => ({ row, index, distance: daysBetween(row.date, target.date) }))
.filter(
({ row, index, distance }) =>
!used.has(index) &&
Math.sign(row.amountCents) === Math.sign(target.amountCents) &&
row.amountCents !== 0 &&
distance <= maxDays
);
// Same-day batches are small; cap the search so a busy day can't explode combinatorially.
if (near.length > 40) near.length = 40;
let best: { indexes: number[]; distance: number } | null = null;
const search = (size: number, start: number, chosen: typeof near, sum: number) => {
if (chosen.length === size) {
if (sum !== target.amountCents) return;
const distance = chosen.reduce((total, item) => total + item.distance, 0);
if (!best || distance < best.distance)
best = { indexes: chosen.map((item) => item.index), distance };
return;
}
for (let i = start; i < near.length; i++)
search(size, i + 1, [...chosen, near[i]], sum + near[i].row.amountCents);
};
for (let size = 2; size <= maxParts && !best; size++) search(size, 0, [], 0);
const found = best as { indexes: number[] } | null;
return found ? found.indexes : null;
}

View File

@@ -28,10 +28,13 @@ export async function uniqueSlug(title: string): Promise<string> {
return slug;
}
/** /lists/db is the database browser, so no list may use that slug. */
export const RESERVED_LIST_SLUGS = new Set(['db']);
export async function uniqueListSlug(name: string): Promise<string> {
const base = slugify(name, 'list');
let slug = base;
let suffix = 2;
let slug = RESERVED_LIST_SLUGS.has(base) ? `${base}-2` : base;
let suffix = RESERVED_LIST_SLUGS.has(base) ? 3 : 2;
while (
(await db.select({ id: metaTables.id }).from(metaTables).where(eq(metaTables.slug, slug)))
.length > 0

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import seed from '../../../data/tasks/daisy-import.json';
import { parseQuickTitle, seedFile, statusTimestamps, taskInput } from './tasks';
describe('task tracker', () => {
it('parses the seed file', () => {
const parsed = seedFile.parse(seed);
expect(parsed.tasks.length).toBeGreaterThan(50);
expect(parsed.goals.length).toBeGreaterThan(3);
const slugs = new Set(parsed.areas.map((a) => a.slug));
for (const t of parsed.tasks) expect(slugs.has(t.area!)).toBe(true);
const goals = new Set(parsed.goals.map((g) => g.title.toLowerCase()));
for (const t of parsed.tasks) if (t.goal) expect(goals.has(t.goal.toLowerCase())).toBe(true);
const keys = parsed.tasks.map((t) => `${t.area}|${t.title.toLowerCase()}`);
expect(new Set(keys).size).toBe(keys.length);
});
it('validates form input', () => {
expect(taskInput.safeParse({ title: ' ' }).success).toBe(false);
expect(taskInput.safeParse({ title: 'x', dueTime: '10:00' }).success).toBe(false);
const ok = taskInput.parse({ title: 'x', due: '2026-10-26', dueTime: '10:00', tags: '#a, b' });
expect(ok.tags).toEqual(['a', 'b']);
expect(ok.status).toBe('todo');
});
it('parses quick-add flags', () => {
expect(parseQuickTitle('Call vet !')).toEqual({
title: 'Call vet',
flagged: true,
priority: 0
});
expect(parseQuickTitle('Pay rent !!!')).toEqual({
title: 'Pay rent',
flagged: true,
priority: 3
});
expect(parseQuickTitle('Sign up (!)')).toEqual({
title: 'Sign up',
flagged: true,
priority: 0
});
expect(parseQuickTitle('Hello!')).toEqual({ title: 'Hello!', flagged: false, priority: 0 });
});
it('sets completion timestamps from the status', () => {
const now = new Date('2026-10-24T10:00:00Z');
expect(statusTimestamps('done', null, now).completedAt).toEqual(now);
expect(statusTimestamps('todo', { startedAt: null, completedAt: now }, now).completedAt).toBe(
null
);
expect(statusTimestamps('in_progress', null, now).startedAt).toEqual(now);
});
});
describe('local times', () => {
it('reads datetime-local values as Amsterdam time', async () => {
const { parseLocalMoment } = await import('./tasks');
expect(parseLocalMoment('2026-10-24T11:50')?.toISOString()).toBe('2026-10-24T09:50:00.000Z');
expect(parseLocalMoment('2026-12-07T09:00')?.toISOString()).toBe('2026-12-07T08:00:00.000Z');
expect(parseLocalMoment('2026-10-24T11:50:00+02:00')?.toISOString()).toBe(
'2026-10-24T09:50:00.000Z'
);
expect(parseLocalMoment('nope')).toBe(null);
});
});

338
src/lib/server/tasks.ts Normal file
View File

@@ -0,0 +1,338 @@
// Task + goal tracker: validation and the idempotent seed import. Shared by
// /trackers/tasks (owner only) and scripts/tasks-import.ts, so it must not import
// anything from SvelteKit.
import { and, eq, isNull, sql } from 'drizzle-orm';
import type { PgAsyncDatabase } from 'drizzle-orm/pg-core';
import * as z from 'zod';
import {
GOAL_STATUSES,
TASK_STATUSES,
todoActivity,
todoAreas,
todoGoals,
todoTasks
} from './db/schema/tasks';
export const TIME_ZONE = 'Europe/Amsterdam';
const blank = (v: unknown) => (typeof v === 'string' && v.trim() === '' ? null : v);
const optText = z.preprocess(blank, z.string().trim().max(10_000).nullish());
const day = z.preprocess(
blank,
z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD')
.refine((s) => !Number.isNaN(Date.parse(`${s}T00:00:00Z`)), 'Invalid date')
.nullish()
);
const clock = z.preprocess(
blank,
z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/, 'Use HH:MM')
.nullish()
);
const moment = z.preprocess(
blank,
z
.string()
.refine((s) => !Number.isNaN(Date.parse(s)), 'Invalid date/time')
.nullish()
);
const tags = z.preprocess(
(v) =>
typeof v === 'string'
? v
.split(/[,\s]+/)
.map((s) => s.replace(/^#/, ''))
.filter(Boolean)
: v,
z.array(z.string().trim().toLowerCase().min(1).max(40)).max(30).default([])
);
const num = z.preprocess(
(v) => (blank(v) === null ? null : typeof v === 'string' ? Number(v.replace(',', '.')) : v),
z.number().finite().nullish()
);
export const areaSeed = z.object({
slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/),
name: z.string().trim().min(1),
description: optText,
color: optText,
sortOrder: z.number().int().default(0)
});
export const goalInput = z.object({
title: z.string().trim().min(1).max(300),
notes: optText,
status: z.enum(GOAL_STATUSES).default('active'),
area: z.preprocess(blank, z.string().nullish()), // area slug
targetDate: day,
metric: optText,
metricUnit: optText,
metricTarget: num,
metricCurrent: num,
flagged: z.coerce.boolean().default(false)
});
export const taskInput = z
.object({
title: z.string().trim().min(1).max(500),
notes: optText,
status: z.enum(TASK_STATUSES).default('todo'),
priority: z.coerce.number().int().min(0).max(3).default(0),
flagged: z.coerce.boolean().default(false),
due: day,
dueTime: clock,
reminderAt: moment,
recurrence: optText,
area: z.preprocess(blank, z.string().nullish()), // area slug
goal: z.preprocess(blank, z.string().nullish()), // goal title
parent: z.preprocess(blank, z.string().nullish()), // parent task title (same area)
tags
})
.refine((t) => !t.dueTime || t.due, {
message: 'A due time needs a due date',
path: ['dueTime']
});
export const seedFile = z.object({
source: z.string().min(1).default('daisy-import'),
areas: z.array(areaSeed).default([]),
goals: z.array(goalInput).default([]),
tasks: z.array(taskInput).default([])
});
export type SeedFile = z.infer<typeof seedFile>;
export type TaskInput = z.infer<typeof taskInput>;
/**
* "Buy milk !" or "! buy milk" → flagged; "!!!" anywhere → priority 3 (TickTick-style).
* Used by quick-add.
*/
export function parseQuickTitle(raw: string): {
title: string;
flagged: boolean;
priority: number;
} {
let title = raw.trim();
let priority = 0;
const bangs = title.match(/(^|\s)(!{1,3})(?=\s|$)/);
if (bangs) {
priority = bangs[2].length === 1 ? 0 : bangs[2].length;
title = title.replace(bangs[0], ' ').replace(/\s+/g, ' ').trim();
}
const flagged = !!bangs || /\(!\)/.test(title);
title = title.replace(/\s*\(!\)\s*/g, ' ').trim();
return { title, flagged, priority };
}
/** Status side effects: completedAt/startedAt follow the status. */
export function statusTimestamps(
status: (typeof TASK_STATUSES)[number],
prev: { startedAt: Date | null; completedAt: Date | null } | null,
now = new Date()
) {
return {
startedAt:
status === 'in_progress' || status === 'done'
? (prev?.startedAt ?? (status === 'in_progress' ? now : null))
: (prev?.startedAt ?? null),
completedAt: status === 'done' || status === 'dropped' ? (prev?.completedAt ?? now) : null
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- any drizzle pg driver
type Db = PgAsyncDatabase<any, any>;
export type ImportResult = {
areas: { inserted: number; existing: number };
goals: { inserted: number; existing: number; updated: number };
tasks: { inserted: number; existing: number; updated: number };
};
/**
* Idempotent import. Areas dedupe on slug, goals on lower(title), tasks on area + title
* (case-insensitive). Existing rows are left alone unless `update` is set, so re-running
* never undoes edits made on the site.
*/
export async function importSeed(
db: Db,
seed: SeedFile,
{ update = false }: { update?: boolean } = {}
): Promise<ImportResult> {
const result: ImportResult = {
areas: { inserted: 0, existing: 0 },
goals: { inserted: 0, existing: 0, updated: 0 },
tasks: { inserted: 0, existing: 0, updated: 0 }
};
await db.transaction(async (tx) => {
const areaIds = new Map<string, number>();
for (const a of seed.areas) {
const [row] = await tx
.insert(todoAreas)
.values({ ...a, description: a.description ?? null, color: a.color ?? null })
.onConflictDoNothing({ target: todoAreas.slug })
.returning({ id: todoAreas.id });
if (row) result.areas.inserted++;
else result.areas.existing++;
}
for (const a of await tx.select({ id: todoAreas.id, slug: todoAreas.slug }).from(todoAreas))
areaIds.set(a.slug, a.id);
const areaId = (slug: string | null | undefined) => {
if (!slug) return null;
const id = areaIds.get(slug);
if (id === undefined) throw new Error(`Unknown area "${slug}"`);
return id;
};
const goalIds = new Map<string, number>();
for (const g of seed.goals) {
const values = {
title: g.title,
notes: g.notes ?? null,
status: g.status,
areaId: areaId(g.area),
targetDate: g.targetDate ?? null,
metric: g.metric ?? null,
metricUnit: g.metricUnit ?? null,
metricTarget: g.metricTarget ?? null,
metricCurrent: g.metricCurrent ?? null,
flagged: g.flagged,
source: seed.source
};
const [existing] = await tx
.select({ id: todoGoals.id })
.from(todoGoals)
.where(sql`lower(${todoGoals.title}) = lower(${g.title})`);
if (existing) {
result.goals.existing++;
if (update) {
await tx.update(todoGoals).set(values).where(eq(todoGoals.id, existing.id));
result.goals.updated++;
}
goalIds.set(g.title.toLowerCase(), existing.id);
} else {
const [row] = await tx.insert(todoGoals).values(values).returning({ id: todoGoals.id });
result.goals.inserted++;
goalIds.set(g.title.toLowerCase(), row.id);
}
}
const goalId = async (title: string | null | undefined) => {
if (!title) return null;
const known = goalIds.get(title.toLowerCase());
if (known) return known;
const [row] = await tx
.select({ id: todoGoals.id })
.from(todoGoals)
.where(sql`lower(${todoGoals.title}) = lower(${title})`);
if (!row) throw new Error(`Unknown goal "${title}"`);
return row.id;
};
const findTask = async (area: number | null, title: string) => {
const [row] = await tx
.select({ id: todoTasks.id })
.from(todoTasks)
.where(
and(
area === null ? isNull(todoTasks.areaId) : eq(todoTasks.areaId, area),
sql`lower(${todoTasks.title}) = lower(${title})`
)
)
.limit(1);
return row?.id ?? null;
};
// Parents first, so subtasks can point at them.
const ordered = [...seed.tasks].sort((a, b) => Number(!!a.parent) - Number(!!b.parent));
for (const [i, t] of ordered.entries()) {
const area = areaId(t.area);
const parentId = t.parent ? await findTask(area, t.parent) : null;
if (t.parent && parentId === null) throw new Error(`Unknown parent task "${t.parent}"`);
const values = {
title: t.title,
notes: t.notes ?? null,
status: t.status,
priority: t.priority,
flagged: t.flagged,
dueDate: t.due ?? null,
dueTime: t.dueTime ?? null,
reminderAt: t.reminderAt ? parseLocalMoment(t.reminderAt) : null,
recurrence: t.recurrence ?? null,
areaId: area,
goalId: await goalId(t.goal),
parentId,
tags: t.tags,
sortOrder: (i + 1) * 10,
source: seed.source,
...statusTimestamps(t.status, null)
};
const existing = await findTask(area, t.title);
if (existing !== null) {
result.tasks.existing++;
if (update) {
const rest: Partial<typeof values> = { ...values };
delete rest.startedAt;
delete rest.completedAt;
await tx.update(todoTasks).set(rest).where(eq(todoTasks.id, existing));
result.tasks.updated++;
}
continue;
}
const [row] = await tx.insert(todoTasks).values(values).returning({ id: todoTasks.id });
await tx.insert(todoActivity).values({
taskId: row.id,
kind: 'created',
toStatus: t.status,
note: `Imported (${seed.source})`
});
result.tasks.inserted++;
}
});
return result;
}
function offsetMinutes(at: Date, timeZone: string): number {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
.formatToParts(at)
.map((p) => [p.type, p.value])
);
const wall = Date.UTC(
+parts.year,
+parts.month - 1,
+parts.day,
+parts.hour,
+parts.minute,
+parts.second
);
return Math.round((wall - at.getTime()) / 60_000);
}
/** "2026-12-07T09:00" (a datetime-local value, Amsterdam wall clock) → Date. ISO with offset passes through. */
export function parseLocalMoment(value: string, timeZone = TIME_ZONE): Date | null {
const local = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(:\d{2})?$/);
if (!local) {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
const guess = new Date(`${local[1]}T${local[2]}${local[3] ?? ':00'}Z`);
if (Number.isNaN(guess.getTime())) return null;
let result = new Date(guess.getTime() - offsetMinutes(guess, timeZone) * 60_000);
const corrected = offsetMinutes(result, timeZone);
result = new Date(guess.getTime() - corrected * 60_000);
return result;
}
/** Today's date in Amsterdam, YYYY-MM-DD. */
export function todayIn(timeZone = TIME_ZONE, now = new Date()): string {
return new Intl.DateTimeFormat('en-CA', { timeZone }).format(now);
}

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest';
import {
mapTask,
mergeCsv,
parseTickTickCsv,
moreComplete,
normTitle,
slugify,
ttDate,
ttDue,
ttPriority,
ttRecurrence,
ttReminder,
ttStatus
} from './ticktick';
describe('ticktick mapping', () => {
it('parses TickTick timestamps', () => {
expect(ttDate('2024-06-11T17:30:36.000+0000')?.toISOString()).toBe('2024-06-11T17:30:36.000Z');
expect(ttDate('')).toBeNull();
});
it('keeps the calendar date of all-day dues and converts timed dues to Amsterdam', () => {
expect(ttDue('2025-03-11T23:00:00.000+0000', true, 'Europe/Amsterdam')).toEqual({
dueDate: '2025-03-12',
dueTime: null
});
expect(ttDue('2026-05-04T04:00:00.000+0000', false, 'Europe/Berlin')).toEqual({
dueDate: '2026-05-04',
dueTime: '06:00'
});
expect(ttDue(undefined, true, undefined)).toEqual({ dueDate: null, dueTime: null });
});
it('maps priority, status, recurrence and reminders', () => {
expect([5, 3, 1, 0, undefined].map(ttPriority)).toEqual([3, 2, 1, 0, 0]);
expect([0, 2, -1].map(ttStatus)).toEqual(['todo', 'done', 'dropped']);
expect(ttRecurrence('RRULE:FREQ=WEEKLY;INTERVAL=1')).toBe('FREQ=WEEKLY;INTERVAL=1');
const due = new Date('2026-01-01T10:00:00Z');
expect(ttReminder(['TRIGGER:-PT60M', 'TRIGGER:PT0S'], due)?.toISOString()).toBe(
'2026-01-01T09:00:00.000Z'
);
expect(ttReminder(['TRIGGER:P0DT9H0M0S'], due)?.toISOString()).toBe('2026-01-01T19:00:00.000Z');
});
it('prefers the more complete status', () => {
expect(moreComplete('done', 'todo')).toBe('done');
expect(moreComplete('todo', 'done')).toBe('done');
expect(moreComplete('in_progress', 'todo')).toBe('in_progress');
});
it('turns checklist items into subtasks', () => {
const rows = mapTask({
id: 't1',
projectId: 'inbox1',
title: ' Groceries ',
kind: 'CHECKLIST',
desc: 'weekly',
status: 2,
priority: 5,
createdTime: '2025-01-01T10:00:00.000+0000',
modifiedTime: '2025-01-02T10:00:00.000+0000',
completedTime: '2025-01-02T09:00:00.000+0000',
items: [
{ id: 'i1', title: 'milk', status: 1, completedTime: '2025-01-02T08:00:00.000+0000' },
{ id: 'i2', title: 'eggs', status: 0 },
{ id: 'i3', title: 'bread', status: 1, completedTime: 978307200000 }
]
} as Parameters<typeof mapTask>[0]);
expect(rows).toHaveLength(4);
// Placeholder completion (2001) falls back to the task's completion.
expect(rows[3].completedAt?.toISOString()).toBe('2025-01-02T09:00:00.000Z');
expect(rows[0]).toMatchObject({
title: 'Groceries',
notes: 'weekly',
status: 'done',
priority: 3
});
expect(rows[0].completedAt?.toISOString()).toBe('2025-01-02T09:00:00.000Z');
expect(rows[1]).toMatchObject({ externalId: 't1:i1', parentExternalId: 't1', status: 'done' });
// Unchecked item of a completed task follows the task.
expect(rows[2]).toMatchObject({ externalId: 't1:i2', status: 'done' });
expect(rows[2].completedAt?.toISOString()).toBe('2025-01-02T09:00:00.000Z');
});
it('slugs and normalises titles', () => {
expect(slugify('Stuhee')).toBe('stuhee');
expect(normTitle('Book haircut!')).toBe('book haircut');
});
});
describe('ticktick backup csv', () => {
const csv = [
'"Date: 2026-10-24+0000"',
'"Status: ',
'0 Normal',
'2 Completed"',
'"Folder Name","List Name","Title","Kind","Tags","Content","Is Check list","Start Date","Due Date","Reminder","Repeat","Priority","Status","Created Time","Completed Time","Order","Timezone","Is All Day","Is Floating","Column Name","Column Order","View Mode","taskId","parentId","projectKind"',
'"","Inbox","Known","TEXT","","","N","","","","","0","2","2025-01-01T10:00:00+0000","2025-01-02T10:00:00+0000","0","Europe/Amsterdam","false","false",,,"list","1","","TASK"',
'"","Inbox","Old won\'t do","TEXT","a, b","line ""one""\nline two","N","","","","","5","-1","2024-01-01T10:00:00+0000","2024-02-01T10:00:00+0000","0","Europe/Amsterdam","false","false",,,"list","2","1","TASK"',
'"Old","Trips","Pack","CHECKLIST","","▫Tea\n▪Jerky\n","Y","","","","","0","0","2023-01-01T10:00:00+0000","","0","Europe/Amsterdam","false","false",,,"list","3","","TASK"',
''
].join('\n');
const api = {
projects: [],
tasks: [
{
id: 'api1',
projectId: 'inbox9',
title: 'Known',
status: 2,
createdTime: '2025-01-01T10:00:00.000+0000'
}
]
};
it('parses the backup, including multi-line quoted fields and checklists', () => {
const t = parseTickTickCsv(csv);
expect(t).toHaveLength(3);
expect(t[1]).toMatchObject({ content: 'line "one"\nline two', tags: ['a', 'b'], status: -1 });
expect(t[2].items).toEqual([
{ id: 'item1', title: 'Tea', status: 0 },
{ id: 'item2', title: 'Jerky', status: 1 }
]);
});
it('skips rows the API already has and keys the rest stably', () => {
const a = mergeCsv(api, parseTickTickCsv(csv));
const b = mergeCsv(api, parseTickTickCsv(csv));
expect(a.report).toMatchObject({
csvRows: 3,
matchedListTitleCreated: 1,
added: 2,
newLists: ['Trips']
});
const added = a.data.tasks.slice(1);
expect(added.map((t) => t.id)).toEqual(b.data.tasks.slice(1).map((t) => t.id));
expect(added[0].id).toMatch(/^csv:/);
expect(added[0]).toMatchObject({ projectId: 'inbox9', parentId: 'api1' });
expect(added[1].projectId).toBe('csvlist:Trips');
expect(a.data.projects).toContainEqual({
id: 'csvlist:Trips',
name: 'Old / Trips',
closed: true
});
});
});

723
src/lib/server/ticktick.ts Normal file
View File

@@ -0,0 +1,723 @@
// TickTick → tasks schema sync. Pure mapping + an idempotent sync, shared by
// scripts/ticktick-import.ts and its tests; must not import anything from SvelteKit.
// Input is the JSON written by scripts/ticktick-export.ts (Open API v1 task objects).
import { createHash } from 'node:crypto';
import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm';
import type { PgAsyncDatabase } from 'drizzle-orm/pg-core';
import { todoActivity, todoAreas, todoTasks, type TaskStatus } from './db/schema/tasks';
import { TIME_ZONE } from './tasks';
export const TICKTICK_SOURCE = 'ticktick';
export type TtItem = {
id: string;
title?: string;
status?: number;
completedTime?: string | number;
sortOrder?: number;
startDate?: string;
isAllDay?: boolean;
timeZone?: string;
};
export type TtTask = {
id: string;
projectId: string;
title?: string;
content?: string;
desc?: string;
status?: number;
priority?: number;
tags?: string[];
dueDate?: string;
startDate?: string;
isAllDay?: boolean;
timeZone?: string;
repeatFlag?: string;
reminders?: string[];
items?: TtItem[];
parentId?: string;
sortOrder?: number;
createdTime?: string;
modifiedTime?: string;
completedTime?: string;
};
export type TtProject = { id: string; name: string; closed?: boolean };
export type TtExport = { projects: TtProject[]; tasks: TtTask[]; inboxId?: string };
/** Hand-picked links: a TickTick task that is clearly the same as an existing task. */
export type TtMerge = { ticktickId: string; title: string };
/** TickTick timestamps look like 2024-06-11T17:30:36.000+0000. */
export function ttDate(v: string | number | null | undefined): Date | null {
if (v === null || v === undefined || v === '') return null;
const d =
typeof v === 'number' ? new Date(v) : new Date(v.replace(/([+-]\d{2})(\d{2})$/, '$1:$2'));
return Number.isNaN(d.getTime()) ? null : d;
}
const parts = (d: Date, timeZone: string) => {
const f = new Intl.DateTimeFormat('en-GB', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(d);
const g = (t: string) => f.find((p) => p.type === t)?.value ?? '';
return { date: `${g('year')}-${g('month')}-${g('day')}`, time: `${g('hour')}:${g('minute')}` };
};
/**
* All-day dues are midnight in the task's own zone (keep that calendar date);
* timed dues become an Amsterdam wall-clock date + time (due_time is Amsterdam).
*/
export function ttDue(
dueDate: string | undefined,
isAllDay: boolean | undefined,
timeZone: string | undefined
): { dueDate: string | null; dueTime: string | null } {
const d = ttDate(dueDate);
if (!d) return { dueDate: null, dueTime: null };
if (isAllDay) return { dueDate: parts(d, validZone(timeZone)).date, dueTime: null };
const p = parts(d, TIME_ZONE);
return { dueDate: p.date, dueTime: p.time };
}
function validZone(z: string | undefined) {
if (!z) return TIME_ZONE;
try {
new Intl.DateTimeFormat('en', { timeZone: z });
return z;
} catch {
return TIME_ZONE;
}
}
/** First reminder: TRIGGER:PT0S / -PT60M / P0DT9H0M0S (relative to the due moment). */
export function ttReminder(reminders: string[] | undefined, due: Date | null): Date | null {
if (!due || !reminders?.length) return null;
let best: Date | null = null;
for (const r of reminders) {
const m = r.match(
/^TRIGGER:(-)?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/
);
if (!m) continue;
const [, neg, w, d, h, min, s] = m;
const ms =
((Number(w ?? 0) * 7 + Number(d ?? 0)) * 86_400 +
Number(h ?? 0) * 3600 +
Number(min ?? 0) * 60 +
Number(s ?? 0)) *
1000;
const at = new Date(due.getTime() + (neg ? -ms : ms));
if (!best || at < best) best = at;
}
return best;
}
export const ttPriority = (p: number | undefined) => (p === 5 ? 3 : p === 3 ? 2 : p === 1 ? 1 : 0);
export const ttStatus = (s: number | undefined): TaskStatus =>
s === 2 ? 'done' : s === -1 ? 'dropped' : 'todo';
/** TickTick stores "RRULE:FREQ=…"; the tracker keeps the bare RFC 5545 rule. */
export const ttRecurrence = (r: string | undefined) => r?.replace(/^RRULE:/, '').trim() || null;
const RANK: Record<TaskStatus, number> = { todo: 0, in_progress: 1, dropped: 2, done: 3 };
/** The more complete of two statuses (done > dropped > in progress > to do). */
export const moreComplete = (a: TaskStatus, b: TaskStatus) => (RANK[b] > RANK[a] ? b : a);
export const slugify = (s: string) =>
s
.normalize('NFKD')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase() || 'list';
export const normTitle = (s: string) =>
s
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim();
export type MappedRow = {
externalId: string;
parentExternalId: string | null;
projectId: string;
title: string;
notes: string | null;
status: TaskStatus;
priority: number;
tags: string[];
dueDate: string | null;
dueTime: string | null;
startAt: Date | null;
timeZone: string | null;
reminderAt: Date | null;
recurrence: string | null;
completedAt: Date | null;
createdAt: Date;
updatedAt: Date;
ttSortOrder: number;
};
/** One TickTick task → its row plus one row per checklist item (as subtasks). */
export function mapTask(t: TtTask): MappedRow[] {
const status = ttStatus(t.status);
const created =
ttDate(t.createdTime) ?? ttDate(t.modifiedTime) ?? ttDate(t.completedTime) ?? new Date(0);
const modified = ttDate(t.modifiedTime) ?? created;
const due = ttDue(t.dueDate, t.isAllDay, t.timeZone);
const notes = (t.content?.trim() || t.desc?.trim() || '').slice(0, 50_000) || null;
const done = status === 'done' || status === 'dropped';
const row: MappedRow = {
externalId: t.id,
parentExternalId: t.parentId || null,
projectId: t.projectId,
title: t.title?.trim() || '(untitled)',
notes,
status,
priority: ttPriority(t.priority),
tags: [...new Set((t.tags ?? []).map((x) => x.toLowerCase()))],
...due,
startAt: ttDate(t.startDate),
timeZone: t.timeZone || null,
reminderAt: ttReminder(t.reminders, ttDate(t.dueDate)),
recurrence: ttRecurrence(t.repeatFlag),
completedAt: done ? (ttDate(t.completedTime) ?? modified) : null,
createdAt: created,
updatedAt: modified,
ttSortOrder: t.sortOrder ?? 0
};
// Some old checklist items carry a placeholder completedTime (2001-01-01): ignore any
// completion before the task existed.
const itemDone = (v: string | number | undefined) => {
const d = ttDate(v);
return d && d.getTime() >= created.getTime() - 86_400_000 ? d : null;
};
const items = (t.items ?? []).map((i): MappedRow => {
// Checklist status: 0 open, 1 (or 2) checked. Unchecked items of a finished task
// follow the task (done → done, won't do → dropped) so they don't resurface as open.
const checked = (i.status ?? 0) !== 0 || status === 'done';
return {
externalId: `${t.id}:${i.id}`,
parentExternalId: t.id,
projectId: t.projectId,
title: i.title?.trim() || '(untitled)',
notes: null,
status: checked ? 'done' : status === 'dropped' ? 'dropped' : 'todo',
priority: 0,
tags: [],
...ttDue(i.startDate, i.isAllDay ?? true, i.timeZone ?? t.timeZone),
startAt: null,
timeZone: i.timeZone || t.timeZone || null,
reminderAt: null,
recurrence: null,
completedAt: checked
? (itemDone(i.completedTime) ?? row.completedAt ?? modified)
: status === 'dropped'
? row.completedAt
: null,
createdAt: created,
updatedAt: modified,
ttSortOrder: i.sortOrder ?? 0
};
});
return [row, ...items];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Db = PgAsyncDatabase<any, any>;
export type SyncReport = {
areas: { inserted: string[]; existing: string[] };
tasks: { inserted: number; updated: number; unchanged: number; subtasks: number };
merged: { ticktick: string; task: string; status: TaskStatus }[];
/** Rows inserted by this run. */
newByStatus: Partial<Record<TaskStatus, number>>;
newByArea: Record<string, number>;
byStatus: Record<TaskStatus, number>;
byArea: Record<string, number>;
range: { created: [string, string] | null; completed: [string, string] | null };
};
/**
* Upsert a TickTick export (idempotent). Rows are keyed on external_id = TickTick id
* (checklist items: "<taskId>:<itemId>"). A TickTick row is only overwritten when
* TickTick modified it after our last update, so edits made on the site win until
* the task changes in TickTick again. Links to existing (e.g. daisy-import) tasks:
* the explicit `merges` list, plus an exact title match when the TickTick task is
* still open or was finished after that task was created. A linked task keeps its
* own fields; only its status moves to the more complete of the two.
*/
export async function syncTickTick(
db: Db,
data: TtExport,
opts: { merges?: TtMerge[] } = {}
): Promise<SyncReport> {
const report: SyncReport = {
areas: { inserted: [], existing: [] },
tasks: { inserted: 0, updated: 0, unchanged: 0, subtasks: 0 },
merged: [],
newByStatus: {},
newByArea: {},
byStatus: { todo: 0, in_progress: 0, done: 0, dropped: 0 },
byArea: {},
range: { created: null, completed: null }
};
const rows = data.tasks.flatMap(mapTask);
report.tasks.subtasks = rows.filter((r) => r.parentExternalId).length;
return db.transaction(async (tx) => {
// Areas: one per TickTick list. The inbox and any list no longer returned
// (closed/deleted) get their own area.
const projectName = new Map(data.projects.map((p) => [p.id, p.name]));
const areaFor = new Map<string, number>();
const [{ maxSort }] = await tx
.select({ maxSort: sql<number>`coalesce(max(${todoAreas.sortOrder}), 0)` })
.from(todoAreas);
let sort = Number(maxSort);
for (const pid of new Set(rows.map((r) => r.projectId))) {
const isInbox = pid.startsWith('inbox');
const raw = isInbox ? 'Inbox' : (projectName.get(pid) ?? `TickTick list ${pid.slice(-6)}`);
const name = raw.replace(/^[^\p{L}\p{N}]+/u, '').trim() || raw;
const slug = slugify(name);
const [found] = await tx.select().from(todoAreas).where(eq(todoAreas.slug, slug));
if (found) {
areaFor.set(pid, found.id);
report.areas.existing.push(slug);
continue;
}
const [ins] = await tx
.insert(todoAreas)
.values({
slug,
name,
description: isInbox ? 'TickTick inbox' : `TickTick list "${raw}"`,
sortOrder: (sort += 10)
})
.returning({ id: todoAreas.id });
areaFor.set(pid, ins.id);
report.areas.inserted.push(slug);
}
// Site sort order: rank by TickTick sortOrder within each list (TickTick values overflow int4).
const siteSort = new Map<string, number>();
const perProject = new Map<string, MappedRow[]>();
for (const r of rows) {
const list = perProject.get(r.projectId) ?? [];
list.push(r);
perProject.set(r.projectId, list);
}
for (const list of perProject.values())
list
.sort(
(a, b) => a.ttSortOrder - b.ttSortOrder || a.createdAt.getTime() - b.createdAt.getTime()
)
.forEach((r, i) => siteSort.set(r.externalId, (i + 1) * 10));
const existing = new Map(
(
await tx
.select({
id: todoTasks.id,
source: todoTasks.source,
externalId: todoTasks.externalId,
status: todoTasks.status,
updatedAt: todoTasks.updatedAt
})
.from(todoTasks)
.where(isNotNull(todoTasks.externalId))
).map((r) => [r.externalId!, r])
);
// Unlinked non-TickTick tasks are merge candidates.
const candidates = await tx
.select({
id: todoTasks.id,
title: todoTasks.title,
status: todoTasks.status,
createdAt: todoTasks.createdAt
})
.from(todoTasks)
.where(
and(
isNull(todoTasks.externalId),
sql`coalesce(${todoTasks.source}, '') <> ${TICKTICK_SOURCE}`
)
);
const byTitle = new Map<string, typeof candidates>();
for (const c of candidates) {
const k = normTitle(c.title);
byTitle.set(k, [...(byTitle.get(k) ?? []), c]);
}
const explicit = new Map((opts.merges ?? []).map((m) => [m.ticktickId, normTitle(m.title)]));
const linked = new Set<number>();
const idFor = new Map<string, number>();
const activity: (typeof todoActivity.$inferInsert)[] = [];
for (const r of rows) {
let prev = existing.get(r.externalId);
// A task first imported from the backup CSV (csv:… key) that the API now returns:
// move it onto the API id instead of inserting a duplicate.
if (!prev && !r.externalId.startsWith('csv:') && !r.parentExternalId) {
const alias = existing.get(
csvKey(listNameOf(data.projects, r.projectId), r.title, r.createdAt)
);
if (alias && alias.source === TICKTICK_SOURCE) {
await tx
.update(todoTasks)
.set({ externalId: r.externalId })
.where(eq(todoTasks.id, alias.id));
existing.delete(alias.externalId!);
prev = { ...alias, externalId: r.externalId };
existing.set(r.externalId, prev);
}
}
const fields = {
title: r.title,
notes: r.notes,
status: r.status,
priority: r.priority,
tags: r.tags,
dueDate: r.dueDate,
dueTime: r.dueTime,
startAt: r.startAt,
timeZone: r.timeZone,
reminderAt: r.reminderAt,
recurrence: r.recurrence,
completedAt: r.completedAt,
areaId: areaFor.get(r.projectId) ?? null
};
if (prev && prev.source === TICKTICK_SOURCE) {
idFor.set(r.externalId, prev.id);
if (r.updatedAt > prev.updatedAt) {
await tx.update(todoTasks).set(fields).where(eq(todoTasks.id, prev.id));
if (prev.status !== r.status)
activity.push({
taskId: prev.id,
kind: 'status',
fromStatus: prev.status,
toStatus: r.status,
note: 'TickTick sync',
createdAt: r.updatedAt
});
report.tasks.updated++;
} else report.tasks.unchanged++;
continue;
}
if (prev) {
// Linked earlier to a task from another source: only upgrade its status.
idFor.set(r.externalId, prev.id);
const next = moreComplete(prev.status, r.status);
if (next !== prev.status) {
await tx
.update(todoTasks)
.set({ status: next, completedAt: r.completedAt })
.where(eq(todoTasks.id, prev.id));
report.tasks.updated++;
} else report.tasks.unchanged++;
continue;
}
if (!r.parentExternalId) {
const want = explicit.get(r.externalId);
const pool = (want ? byTitle.get(want) : byTitle.get(normTitle(r.title))) ?? [];
const match = pool.filter(
(c) =>
!linked.has(c.id) &&
(want || r.status === 'todo' || (r.completedAt && r.completedAt >= c.createdAt))
);
if (match.length === 1) {
const c = match[0];
linked.add(c.id);
const next = moreComplete(c.status, r.status);
await tx
.update(todoTasks)
.set({
externalId: r.externalId,
status: next,
...(next !== c.status ? { completedAt: r.completedAt } : {})
})
.where(eq(todoTasks.id, c.id));
activity.push({
taskId: c.id,
kind: 'note',
note: `Linked to TickTick task "${r.title}" (${r.status})`
});
idFor.set(r.externalId, c.id);
report.merged.push({ ticktick: r.title, task: c.title, status: next });
continue;
}
}
const [ins] = await tx
.insert(todoTasks)
.values({
...fields,
sortOrder: siteSort.get(r.externalId) ?? 0,
source: TICKTICK_SOURCE,
externalId: r.externalId,
createdAt: r.createdAt,
updatedAt: r.updatedAt
})
.returning({ id: todoTasks.id });
idFor.set(r.externalId, ins.id);
activity.push({
taskId: ins.id,
kind: 'created',
toStatus: r.status,
note: 'Imported (ticktick)',
createdAt: r.createdAt
});
report.tasks.inserted++;
report.newByStatus[r.status] = (report.newByStatus[r.status] ?? 0) + 1;
const an = listNameOf(data.projects, r.projectId);
report.newByArea[an] = (report.newByArea[an] ?? 0) + 1;
}
// Parents (TickTick subtasks and checklist items), set after every row exists.
for (const r of rows) {
if (!r.parentExternalId) continue;
const me = idFor.get(r.externalId);
const parent = idFor.get(r.parentExternalId);
const mine = existing.get(r.externalId);
if (!me || !parent || me === parent) continue;
if (mine && mine.source !== TICKTICK_SOURCE) continue;
await tx
.update(todoTasks)
.set({ parentId: parent })
.where(and(eq(todoTasks.id, me), sql`${todoTasks.parentId} IS DISTINCT FROM ${parent}`));
}
for (let i = 0; i < activity.length; i += 500)
await tx.insert(todoActivity).values(activity.slice(i, i + 500));
// Report from the database: everything that came from (or is linked to) TickTick.
const stats = await tx
.select({
status: todoTasks.status,
area: todoAreas.name,
n: sql<number>`count(*)::int`
})
.from(todoTasks)
.leftJoin(todoAreas, eq(todoAreas.id, todoTasks.areaId))
.where(isNotNull(todoTasks.externalId))
.groupBy(todoTasks.status, todoAreas.name);
for (const s of stats) {
report.byStatus[s.status] += s.n;
const a = s.area ?? 'No area';
report.byArea[a] = (report.byArea[a] ?? 0) + s.n;
}
const iso = (d: Date) => d.toISOString().slice(0, 10);
const created = rows
.map((r) => r.createdAt)
.filter((d) => d.getTime() > 0)
.sort((a, b) => +a - +b);
const completed = rows
.map((r) => r.completedAt)
.filter((d): d is Date => !!d)
.sort((a, b) => +a - +b);
if (created.length) report.range.created = [iso(created[0]), iso(created.at(-1)!)];
if (completed.length) report.range.completed = [iso(completed[0]), iso(completed.at(-1)!)];
return report;
});
}
// --- TickTick backup CSV (Settings → Backup & Import) --------------------------------
// The backup has local row numbers as taskId, so CSV-only tasks get a stable key:
// "csv:" + hash(list, title, created second) (+ ":n" for repeats). Rows that also came
// through the API are dropped in favour of the API copy (matched on list + title +
// created time, falling back to title + created time).
/** Minimal RFC 4180 parser (quoted fields may contain commas, quotes and newlines). */
export function parseCsv(text: string): string[][] {
const out: string[][] = [];
let row: string[] = [];
let field = '';
let quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quoted) {
if (c === '"') {
if (text[i + 1] === '"') {
field += '"';
i++;
} else quoted = false;
} else field += c;
} else if (c === '"') quoted = true;
else if (c === ',') {
row.push(field);
field = '';
} else if (c === '\n' || c === '\r') {
if (c === '\r' && text[i + 1] === '\n') i++;
row.push(field);
out.push(row);
row = [];
field = '';
} else field += c;
}
if (field || row.length) {
row.push(field);
out.push(row);
}
return out;
}
export const csvKey = (list: string, title: string, created: Date | null) =>
'csv:' +
createHash('sha1')
.update(
`${list}\u0000${title.trim()}\u0000${created ? Math.floor(created.getTime() / 1000) : ''}`
)
.digest('hex')
.slice(0, 20);
export type CsvTask = TtTask & {
csvId: string;
csvParent: string;
listName: string;
folder: string;
};
/** Backup CSV → API-shaped tasks (ids still unset; see mergeCsv). */
export function parseTickTickCsv(text: string): CsvTask[] {
const records = parseCsv(text.replace(/^\uFEFF/, ''));
const h = records.findIndex((r) => r[0] === 'Folder Name');
if (h < 0) throw new Error('Not a TickTick backup: no "Folder Name" header row');
const cols = records[h];
const tasks: CsvTask[] = [];
for (const rec of records.slice(h + 1)) {
if (rec.length < cols.length - 1) continue;
const r = Object.fromEntries(cols.map((c, i) => [c, rec[i] ?? '']));
const isChecklist = r['Kind'] === 'CHECKLIST' || r['Is Check list'] === 'Y';
const lines = (r['Content'] ?? '').split('\n');
const items: TtItem[] = [];
const noteLines: string[] = [];
if (isChecklist)
for (const l of lines) {
const m = l.match(/^\s*([▫▪])\s*(.*)$/u);
if (m)
items.push({ id: `item${items.length + 1}`, title: m[2], status: m[1] === '▪' ? 1 : 0 });
else noteLines.push(l);
}
const num = (s: string) => (s === '' || Number.isNaN(Number(s)) ? undefined : Number(s));
tasks.push({
id: '',
csvId: r['taskId'],
csvParent: r['parentId'],
listName: r['List Name'],
folder: r['Folder Name'],
projectId: '',
title: r['Title'],
content: isChecklist ? noteLines.join('\n') : r['Content'],
status: num(r['Status']),
priority: num(r['Priority']),
tags: r['Tags']
? r['Tags']
.split(',')
.map((t) => t.trim())
.filter(Boolean)
: [],
startDate: r['Start Date'] || undefined,
dueDate: r['Due Date'] || undefined,
isAllDay: r['Is All Day'] === 'true',
timeZone: r['Timezone'] || undefined,
repeatFlag: r['Repeat'] || undefined,
reminders: r['Reminder']
? r['Reminder']
.split(',')
.map((x) => (x.startsWith('TRIGGER:') ? x : `TRIGGER:${x.trim()}`))
: [],
items,
sortOrder: num(r['Order']),
createdTime: r['Created Time'] || undefined,
modifiedTime: r['Completed Time'] || r['Created Time'] || undefined,
completedTime: r['Completed Time'] || undefined
});
}
return tasks;
}
export const listNameOf = (projects: TtProject[], projectId: string) =>
projectId.startsWith('inbox')
? 'Inbox'
: (projects.find((p) => p.id === projectId)?.name ?? projectId);
export type CsvMergeReport = {
csvRows: number;
matchedListTitleCreated: number;
matchedTitleCreated: number;
added: number;
newLists: string[];
};
/** Add the CSV-only tasks to an API export (returns a new export + what matched). */
export function mergeCsv(
data: TtExport,
csv: CsvTask[]
): { data: TtExport; report: CsvMergeReport } {
const sec = (v: string | undefined) => {
const d = ttDate(v);
return d ? String(Math.floor(d.getTime() / 1000)) : '';
};
const k1 = new Map<string, string[]>();
const k2 = new Map<string, string[]>();
for (const t of data.tasks) {
const title = (t.title ?? '').trim();
const a = `${listNameOf(data.projects, t.projectId)}\u0000${title}\u0000${sec(t.createdTime)}`;
const b = `${title}\u0000${sec(t.createdTime)}`;
k1.set(a, [...(k1.get(a) ?? []), t.id]);
k2.set(b, [...(k2.get(b) ?? []), t.id]);
}
const used = new Set<string>();
const take = (m: Map<string, string[]>, k: string) => {
const id = m.get(k)?.find((x) => !used.has(x));
if (id) used.add(id);
return id;
};
const report: CsvMergeReport = {
csvRows: csv.length,
matchedListTitleCreated: 0,
matchedTitleCreated: 0,
added: 0,
newLists: []
};
const projects = [...data.projects];
const inboxId =
data.inboxId ?? data.tasks.find((t) => t.projectId.startsWith('inbox'))?.projectId ?? 'inbox';
const projectFor = (list: string, folder: string) => {
if (list === 'Inbox') return inboxId;
const p = projects.find((x) => x.name === list);
if (p) return p.id;
const id = `csvlist:${list}`;
projects.push({ id, name: folder ? `${folder} / ${list}` : list, closed: true });
report.newLists.push(list);
return id;
};
const idOf = new Map<string, string>(); // csv taskId → API id or csv key
const keyCount = new Map<string, number>();
const added: CsvTask[] = [];
for (const t of csv) {
const title = (t.title ?? '').trim();
let id = take(k1, `${t.listName}\u0000${title}\u0000${sec(t.createdTime)}`);
if (id) report.matchedListTitleCreated++;
else if ((id = take(k2, `${title}\u0000${sec(t.createdTime)}`))) report.matchedTitleCreated++;
if (!id) {
const base = csvKey(t.listName, title, ttDate(t.createdTime));
const n = (keyCount.get(base) ?? 0) + 1;
keyCount.set(base, n);
id = n === 1 ? base : `${base}:${n}`;
added.push(t);
}
idOf.set(t.csvId, id);
t.id = id;
}
const tasks: TtTask[] = [...data.tasks];
for (const t of added) {
const { csvId, csvParent, listName, folder, ...task } = t;
void csvId;
tasks.push({
...task,
projectId: projectFor(listName, folder),
parentId: (csvParent && idOf.get(csvParent)) || undefined
});
}
report.added = added.length;
return { data: { ...data, projects, tasks }, report };
}

View File

@@ -34,10 +34,10 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/projects/energy': 'Energy',
'/projects/game-tracker': 'Game Tracker',
'/projects/language-learner': 'Language Learner',
'/projects/money-tracker': 'Money Tracker',
'/projects/spotify-beatmap-finder': 'osu! map finder',
'/projects/progress': 'Progress',
'/projects/world-opinions': 'World Opinions'
'/projects/world-opinions': 'World Opinions',
'/trackers/money': 'Money'
};
const cleaned = path.replace(/\/$/, '') || '/';
const title =

View File

@@ -2,10 +2,21 @@ import { requireAdmin } from '#lib/server/authorization.js';
import { uniqueListSlug } from '#lib/server/slugify.js';
import { listImageUrl } from '#lib/server/r2.js';
import { createList, listSummaries } from '#lib/server/lists.js';
import { listTables, type TableSummary } from '#lib/server/data-editor.js';
import { isOwner } from '#lib/server/owner.js';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => ({ lists: await listSummaries() });
export const load: PageServerLoad = async (event) => {
const owner = isOwner(event);
// The database browser (every schema/table) is only ever loaded for the owner.
const [lists, tables] = await Promise.all([
listSummaries(),
owner ? listTables() : Promise.resolve([] as TableSummary[])
]);
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
return { lists, tables };
};
export const actions: Actions = {
newList: async (event) => {

View File

@@ -14,6 +14,30 @@
import { Input } from '#lib/components/ui/input/index.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let tableFilter = $state('');
const schemas = $derived.by(() => {
const q = tableFilter.trim().toLowerCase();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
const groups = new Map<string, typeof data.tables>();
for (const t of data.tables) {
if (q && !`${t.schema}.${t.name} ${t.displayName}`.toLowerCase().includes(q)) continue;
groups.set(t.schema, [...(groups.get(t.schema) ?? []), t]);
}
// lists first, then alphabetical
return [...groups].sort(([a], [b]) =>
a === 'lists' ? -1 : b === 'lists' ? 1 : a.localeCompare(b)
);
});
const totalRows = $derived(data.tables.reduce((n, t) => n + (t.rows ?? 0), 0));
const badge = (access: string, relkind: string) =>
access === 'open'
? { text: 'editable', cls: 'border-emerald-600/40 text-emerald-500' }
: access === 'core'
? { text: 'app table', cls: 'border-amber-500/40 text-amber-500' }
: {
text: relkind === 'v' || relkind === 'm' ? 'view' : 'read-only',
cls: 'border-[var(--site-border)] text-[var(--site-muted)]'
};
let uploadError = $state('');
let uploading = $state(false);
const uploadPicture: SubmitFunction = async ({ formData, cancel }) => {
@@ -137,4 +161,60 @@
</li>
{/each}
</ul>
{#if data.isOwner && data.tables.length}
<section id="database" class="mt-16">
<div class="mb-4 flex flex-wrap items-end justify-between gap-4">
<div>
<h2 class="text-2xl font-semibold tracking-tight">database</h2>
<p class="mt-1 text-sm text-[var(--site-muted)]">
{data.tables.length} tables in {new Set(data.tables.map((t) => t.schema)).size} schemas, ~{totalRows.toLocaleString(
'en-GB'
)} rows. Only you can see this. Lists are editable; app tables are read-only until you unlock
them; a new list is a new table.
</p>
</div>
<input
type="search"
bind:value={tableFilter}
placeholder="find a table"
aria-label="Find a table"
class="w-56 rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-1.5 text-sm"
/>
</div>
{#each schemas as [schema, tables] (schema)}
<details open class="border-t border-[var(--site-border)] py-3" id="schema-{schema}">
<summary class="cursor-pointer text-lg font-medium">
{schema}
<span class="ml-2 text-sm font-normal text-[var(--site-muted)]"
>{tables.length} {tables.length === 1 ? 'table' : 'tables'}</span
>
</summary>
<ul class="mt-2 grid gap-x-6 sm:grid-cols-2 lg:grid-cols-3">
{#each tables as t (t.name)}
{@const b = badge(t.access, t.relkind)}
<li>
<a
href={resolve('/lists/db/[schema]/[table]', { schema: t.schema, table: t.name })}
class="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-[var(--site-hover)]"
>
<span class="min-w-0 flex-1 truncate" title={t.description ?? t.displayName}
>{t.name}</span
>
<span class={['rounded-full border px-1.5 text-[10px]', b.cls]}>{b.text}</span>
<span class="w-16 text-right text-xs text-[var(--site-muted)] tabular-nums">
{t.rows === null
? '–'
: `${t.approximate ? '~' : ''}${t.rows.toLocaleString('en-GB')}`}
</span>
</a>
</li>
{/each}
</ul>
</details>
{:else}
<p class="text-[var(--site-muted)]">no tables match</p>
{/each}
</section>
{/if}
</div>

View File

@@ -3,7 +3,7 @@ import { ORIGIN } from '$app/env/private';
import { metaTables } from '#lib/server/db/schema/index.js';
import { requireAdmin } from '#lib/server/authorization.js';
import { deleteObject, listImageKey, listImageUrl } from '#lib/server/r2.js';
import { validSlug } from '#lib/server/slugify.js';
import { RESERVED_LIST_SLUGS, validSlug } from '#lib/server/slugify.js';
import {
archiveList,
deleteItem,
@@ -109,7 +109,7 @@ export const actions: Actions = {
const picture = form.get('picture');
if (!name || name.length > 200)
return fail(400, { message: 'Enter a name of 1–200 characters' });
if (!validSlug(slug) || slug.length > 63)
if (!validSlug(slug) || slug.length > 63 || RESERVED_LIST_SLUGS.has(slug))
return fail(400, { message: 'Use a lowercase slug with letters, numbers, and hyphens.' });
if (picture instanceof File && picture.size > 0)
return fail(400, { message: 'Enable JavaScript to upload an image.' });

View File

@@ -99,6 +99,13 @@
</p>
</div>
</div>
{#if data.isOwner}<a
href={resolve('/lists/db/[schema]/[table]', {
schema: 'lists',
table: data.list.tableName
})}
class="text-base underline underline-offset-4">table view</a
>{/if}
{#if data.isAdmin}<Button
type="button"
onclick={() => (managing = !managing)}

View File

@@ -0,0 +1,10 @@
import { requireOwner } from '#lib/server/owner.js';
import type { LayoutServerLoad } from './$types';
// Owner only: everyone else gets a plain 404. Endpoints below check again themselves,
// and hooks.server.ts refuses every non-GET request under /lists/db for non-owners.
export const load: LayoutServerLoad = (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {};
};

View File

@@ -0,0 +1,9 @@
import { requireOwner } from '#lib/server/owner.js';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
// The database overview lives on /lists itself (shown to the owner only).
export const load: PageServerLoad = (event) => {
requireOwner(event);
redirect(307, '/lists#database');
};

View File

@@ -0,0 +1,18 @@
import { readRows, DataError } from '#lib/server/data-editor.js';
import { ownerTable, rowQuery } from '#lib/server/data-route.js';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const { table } = await ownerTable(event);
const query = rowQuery(event.url);
try {
const result = await readRows(table, query);
return { table, query, ...result, queryError: null as string | null };
} catch (e) {
if (!(e instanceof DataError)) throw e;
if (e.status === 404) error(404, 'Not Found');
const result = await readRows(table, { ...query, filters: [], q: '', sort: undefined });
return { table, query, ...result, queryError: e.message };
}
};

View File

@@ -0,0 +1,806 @@
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import * as Dialog from '#lib/components/ui/dialog/index.js';
import * as AlertDialog from '#lib/components/ui/alert-dialog/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import FieldInput from '#lib/components/data/FieldInput.svelte';
import {
display,
fromEditor,
shortType,
toEditor,
type Col
} from '#lib/components/data/data-format.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const t = $derived(data.table);
const cols = $derived(t.columns as Col[]);
const base = $derived(`/lists/db/${t.schema}/${t.name}`);
const unlockKey = $derived(`data-unlock:${t.schema}.${t.name}`);
// ----- lock state -----
let unlockedTables = $state<Record<string, boolean>>({});
const unlocked = $derived(Boolean(unlockedTables[unlockKey]));
const editable = $derived(t.access === 'open' || (t.access === 'core' && unlocked));
let unlockOpen = $state(false);
function restoreUnlock(node: HTMLElement) {
const key = node.dataset.key!;
if (sessionStorage.getItem(key) === '1') unlockedTables[key] = true;
}
function setUnlocked(on: boolean) {
unlockedTables[unlockKey] = on;
if (on) sessionStorage.setItem(unlockKey, '1');
else sessionStorage.removeItem(unlockKey);
}
// ----- query state (lives in the URL) -----
const params = $derived(page.url.searchParams);
const pageNo = $derived(Math.max(1, Number(params.get('page') ?? 1) || 1));
const limit = $derived(data.query.limit ?? 50);
const pages = $derived(data.total === null ? null : Math.max(1, Math.ceil(data.total / limit)));
let search = $state('');
function syncSearch(node: HTMLInputElement) {
search = params.get('q') ?? '';
node.value = search;
}
function navigate(change: Record<string, string | string[] | null>, keepPage = false) {
const url = new URL(page.url.href);
for (const [k, v] of Object.entries(change)) {
url.searchParams.delete(k);
if (Array.isArray(v)) for (const x of v) url.searchParams.append(k, x);
else if (v !== null && v !== '') url.searchParams.set(k, v);
}
if (!keepPage) url.searchParams.delete('page');
goto(url, { reset: false, replace: true });
}
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function onSearch(value: string) {
search = value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => navigate({ q: value }), 250);
}
function sortBy(c: Col) {
const sort = params.get('sort');
const dir = params.get('dir');
if (sort !== c.name) navigate({ sort: c.name, dir: 'asc' });
else if (dir !== 'desc') navigate({ sort: c.name, dir: 'desc' });
else navigate({ sort: null, dir: null });
}
const filters = $derived(params.getAll('f'));
let fCol = $state('');
let fOp = $state('contains');
let fVal = $state('');
const OPS: [string, string][] = [
['contains', 'contains'],
['eq', '='],
['ne', '≠'],
['gt', '>'],
['gte', '≥'],
['lt', '<'],
['lte', '≤'],
['null', 'is null'],
['notnull', 'is not null']
];
function addFilter(e: SubmitEvent) {
e.preventDefault();
const col = fCol || cols[0]?.name;
if (!col) return;
const f = fOp === 'null' || fOp === 'notnull' ? `${col}:${fOp}` : `${col}:${fOp}:${fVal}`;
navigate({ f: [...filters, f] });
fVal = '';
}
function filterLabel(f: string) {
const [c, op, ...rest] = f.split(':');
const sym = OPS.find(([k]) => k === op)?.[1] ?? op;
return `${c} ${sym} ${rest.join(':')}`.trim();
}
// ----- rows, selection, editing -----
type Row = Record<string, unknown>;
const rows = $derived(data.rows as Row[]);
const pkOf = (r: Row) => Object.fromEntries(t.primaryKey.map((k) => [k, r[k]]));
const keyOf = (r: Row) => JSON.stringify(t.primaryKey.map((k) => r[k]));
let selected = $state<Record<string, boolean>>({});
const selectedRows = $derived(rows.filter((r) => selected[keyOf(r)]));
let active = $state({ r: 0, c: 0 });
let editing = $state<{ r: number; c: number } | null>(null);
let draft = $state<string | null>(null);
let saving = $state(false);
let message = $state<{ kind: 'error' | 'ok'; text: string } | null>(null);
let grid = $state<HTMLTableElement>();
async function api(
path: string,
method: string,
body?: unknown,
raw?: string | Blob,
type?: string
) {
const res = await fetch(`${base}/${path}`, {
method,
headers: {
'content-type': type ?? 'application/json',
...(unlocked ? { 'x-data-unlock': '1' } : {})
},
body: raw ?? (body === undefined ? undefined : JSON.stringify(body))
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.message ?? `Request failed (${res.status})`);
return json;
}
function flash(kind: 'error' | 'ok', text: string) {
message = { kind, text };
if (kind === 'ok') setTimeout(() => message?.text === text && (message = null), 2500);
}
function focusCell(r: number, c: number) {
active = {
r: Math.max(0, Math.min(rows.length - 1, r)),
c: Math.max(0, Math.min(cols.length - 1, c))
};
queueMicrotask(() =>
grid?.querySelector<HTMLElement>(`[data-cell="${active.r}:${active.c}"]`)?.focus()
);
}
function startEdit(r: number, c: number) {
const col = cols[c];
if (!editable || !t.canUpdate || col.readOnly) return;
editing = { r, c };
draft = toEditor(col, rows[r][col.name]);
}
async function saveEdit(move?: 'next' | 'prev') {
if (!editing || saving) return;
const { r, c } = editing;
const col = cols[c];
const before = toEditor(col, rows[r][col.name]);
if (draft === before) {
editing = null;
if (move) {
focusCell(r, c + (move === 'next' ? 1 : -1));
if (move) startEdit(active.r, active.c);
} else focusCell(r, c);
return;
}
saving = true;
try {
await api('rows', 'PATCH', {
pk: pkOf(rows[r]),
values: { [col.name]: fromEditor(col, draft) }
});
editing = null;
await invalidateAll();
flash('ok', 'Saved');
focusCell(r, move ? c + (move === 'next' ? 1 : -1) : c);
if (move) startEdit(active.r, active.c);
} catch (e) {
flash('error', (e as Error).message);
} finally {
saving = false;
}
}
function cancelEdit() {
if (!editing) return;
const { r, c } = editing;
editing = null;
focusCell(r, c);
}
function cellKey(e: KeyboardEvent, r: number, c: number) {
if (editing) return;
const moves: Record<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
ArrowLeft: [0, -1],
ArrowRight: [0, 1]
};
if (moves[e.key]) {
e.preventDefault();
focusCell(r + moves[e.key][0], c + moves[e.key][1]);
} else if (e.key === 'Home') {
e.preventDefault();
focusCell(r, 0);
} else if (e.key === 'End') {
e.preventDefault();
focusCell(r, cols.length - 1);
} else if (e.key === 'Enter' || e.key === 'F2') {
e.preventDefault();
startEdit(r, c);
} else if (e.key === ' ' && t.primaryKey.length) {
e.preventDefault();
const k = keyOf(rows[r]);
selected[k] = !selected[k];
}
}
// ----- delete -----
let deleteTargets = $state<Row[]>([]);
let deleteOpen = $state(false);
function askDelete(targets: Row[]) {
deleteTargets = targets;
deleteOpen = true;
}
async function confirmDelete() {
try {
const { deleted } = await api('rows', 'DELETE', { pks: deleteTargets.map(pkOf) });
selected = {};
deleteOpen = false;
await invalidateAll();
flash('ok', `Deleted ${deleted} row${deleted === 1 ? '' : 's'}`);
} catch (e) {
deleteOpen = false;
flash('error', (e as Error).message);
}
}
// ----- new row -----
let newOpen = $state(false);
let newValues = $state<Record<string, string | null>>({});
let newError = $state('');
const insertable = $derived(cols.filter((c) => !c.readOnly));
function openNew() {
newValues = Object.fromEntries(insertable.map((c) => [c.name, null]));
newError = '';
newOpen = true;
}
async function saveNew(e?: SubmitEvent) {
e?.preventDefault();
const values: Record<string, string | null> = {};
for (const c of insertable) {
const v = newValues[c.name];
if (v !== null && v !== undefined) values[c.name] = fromEditor(c, v);
}
try {
await api('rows', 'POST', { values });
newOpen = false;
await invalidateAll();
flash('ok', 'Row added');
} catch (err) {
newError = (err as Error).message;
}
}
// ----- add column (lists only) -----
let colOpen = $state(false);
let colName = $state('');
let colLabel = $state('');
let colType = $state('text');
let colOptions = $state('');
let colError = $state('');
async function addColumn(e: SubmitEvent) {
e.preventDefault();
try {
await api('columns', 'POST', {
name: colName,
label: colLabel,
uiType: colType,
options: colOptions
.split(',')
.map((s) => s.trim())
.filter(Boolean)
});
colOpen = false;
colName = colLabel = colOptions = '';
await invalidateAll();
flash('ok', 'Column added');
} catch (err) {
colError = (err as Error).message;
}
}
// ----- CSV -----
const exportHref = $derived(`${base}/export${page.url.search}`);
let importFile = $state<File | null>(null);
let importOpen = $state(false);
let fileInput = $state<HTMLInputElement>();
function pickImport(e: Event) {
const f = (e.currentTarget as HTMLInputElement).files?.[0];
if (!f) return;
importFile = f;
importOpen = true;
}
async function confirmImport() {
if (!importFile) return;
try {
const { inserted } = await api(
'import',
'POST',
undefined,
await importFile.text(),
'text/csv'
);
flash('ok', `Imported ${inserted} row${inserted === 1 ? '' : 's'}`);
await invalidateAll();
} catch (e) {
flash('error', (e as Error).message);
} finally {
importOpen = false;
importFile = null;
if (fileInput) fileInput.value = '';
}
}
let searchBox = $state<HTMLInputElement>();
function globalKey(e: KeyboardEvent) {
const el = e.target as HTMLElement;
if (
el.closest(
'input, textarea, select, [contenteditable], [role="dialog"], [role="alertdialog"]'
)
)
return;
if (e.key === '/') {
e.preventDefault();
searchBox?.focus();
} else if (e.key === 'n' && editable && t.canInsert) {
e.preventDefault();
openNew();
}
}
const accessBadge = $derived(
t.access === 'open'
? { text: 'list · editable', cls: 'border-emerald-600/40 text-emerald-500' }
: t.access === 'core'
? unlocked
? {
text: 'app table · editing unlocked',
cls: 'border-amber-500/60 bg-amber-500/10 text-amber-500'
}
: { text: 'app table · read-only', cls: 'border-amber-500/40 text-amber-500' }
: {
text: t.relkind === 'v' || t.relkind === 'm' ? 'view · read-only' : 'read-only',
cls: 'border-[var(--site-border)] text-[var(--site-muted)]'
}
);
</script>
<svelte:window onkeydown={globalKey} />
<svelte:head><title>{t.schema}.{t.name} · data</title></svelte:head>
<div class="mx-auto max-w-[96rem] px-4 py-8 sm:px-6" data-key={unlockKey} {@attach restoreUnlock}>
<nav class="mb-2 text-sm text-[var(--site-muted)]">
<a href={resolve('/lists')} class="hover:underline">lists</a> /
<a href="/lists#schema-{t.schema}" class="hover:underline">{t.schema}</a> /
<span class="text-[var(--site-text)]">{t.name}</span>
</nav>
<div class="mb-4 flex flex-wrap items-center gap-3">
<h1 class="text-3xl font-semibold tracking-tight">{t.displayName}</h1>
<span class={['rounded-full border px-2.5 py-0.5 text-xs', accessBadge.cls]}
>{accessBadge.text}</span
>
{#if t.listSlug}<a
class="text-sm underline underline-offset-4"
href={resolve('/lists/[slug]', { slug: t.listSlug })}>list view</a
>{/if}
<span class="ml-auto text-sm text-[var(--site-muted)]">
{#if data.total !== null}{data.approximate ? '~' : ''}{data.total.toLocaleString('en-GB')} rows{:else}rows:
?{/if}
</span>
</div>
{#if t.description}<p class="mb-4 text-sm text-[var(--site-muted)]">{t.description}</p>{/if}
{#if t.access === 'core'}
<div
class="mb-4 flex flex-wrap items-center gap-3 rounded border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-sm"
>
{#if unlocked}
<span
>Editing is unlocked for this table (this browser tab). Changes go straight into data the {t.schema}
app uses, and are logged.</span
>
<Button size="sm" variant="outline" onclick={() => setUnlocked(false)}>lock again</Button>
{:else}
<span>This table belongs to the {t.schema} app, so it is read-only by default.</span>
<Button size="sm" variant="outline" onclick={() => (unlockOpen = true)}
>unlock editing…</Button
>
{/if}
</div>
{/if}
<div class="mb-3 flex flex-wrap items-center gap-2">
<input
bind:this={searchBox}
{@attach syncSearch}
type="search"
placeholder="search all columns ( / )"
class="w-64 rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-1.5 text-sm"
oninput={(e) => onSearch(e.currentTarget.value)}
aria-label="Search rows"
/>
<form class="flex flex-wrap items-center gap-1" onsubmit={addFilter}>
<select
bind:value={fCol}
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm"
aria-label="Filter column"
>
{#each cols as c (c.name)}<option value={c.name}>{c.name}</option>{/each}
</select>
<select
bind:value={fOp}
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm"
aria-label="Filter operator"
>
{#each OPS as [k, label] (k)}<option value={k}>{label}</option>{/each}
</select>
{#if fOp !== 'null' && fOp !== 'notnull'}
<input
bind:value={fVal}
class="w-36 rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm"
placeholder="value"
aria-label="Filter value"
/>
{/if}
<Button size="sm" variant="outline" type="submit">filter</Button>
</form>
<div class="ml-auto flex flex-wrap items-center gap-2">
{#if editable && t.canInsert}
<Button size="sm" onclick={openNew} title="New row (n)">+ row</Button>
<Button size="sm" variant="outline" onclick={() => fileInput?.click()}>import CSV</Button>
<input
bind:this={fileInput}
type="file"
accept=".csv,text/csv"
class="hidden"
onchange={pickImport}
/>
{/if}
{#if t.isList && t.access === 'open'}
<Button
size="sm"
variant="outline"
onclick={() => {
colError = '';
colOpen = true;
}}>+ column</Button
>
{/if}
<a
class="rounded border border-[var(--site-border)] px-3 py-1 text-sm hover:bg-[var(--site-hover)]"
href={exportHref}
data-sveltekit-reload
download>export CSV</a
>
{#if editable && t.canDelete && selectedRows.length}
<Button size="sm" variant="destructive" onclick={() => askDelete(selectedRows)}
>delete {selectedRows.length}</Button
>
{/if}
</div>
</div>
{#if filters.length}
<div class="mb-3 flex flex-wrap gap-2">
{#each filters as f, i (f + i)}
<button
class="rounded-full border border-[var(--site-border)] px-2.5 py-0.5 text-xs hover:bg-[var(--site-hover)]"
onclick={() => navigate({ f: filters.filter((_, j) => j !== i) })}
title="Remove filter">{filterLabel(f)} ✕</button
>
{/each}
</div>
{/if}
{#if data.queryError}<p role="alert" class="mb-3 text-sm text-red-500">{data.queryError}</p>{/if}
{#if message}
<p
role={message.kind === 'error' ? 'alert' : 'status'}
class={['mb-3 text-sm', message.kind === 'error' ? 'text-red-500' : 'text-emerald-500']}
>
{message.text}
</p>
{/if}
<div
class="overflow-auto rounded border border-[var(--site-border)]"
style="max-height: calc(100vh - 16rem)"
>
<table
bind:this={grid}
class="w-full border-collapse text-sm"
role="grid"
aria-rowcount={data.total ?? -1}
>
<thead class="sticky top-0 z-10 bg-[var(--site-surface)]">
<tr>
{#if t.primaryKey.length}
<th class="w-8 border-b border-[var(--site-border)] px-2">
<input
type="checkbox"
aria-label="Select all rows on this page"
checked={rows.length > 0 && selectedRows.length === rows.length}
onchange={(e) =>
(selected = e.currentTarget.checked
? Object.fromEntries(rows.map((r) => [keyOf(r), true]))
: {})}
/>
</th>
{/if}
{#each cols as c (c.name)}
{@const sorted = params.get('sort') === c.name}
<th
class="border-b border-[var(--site-border)] px-2 py-1.5 text-left align-bottom font-medium whitespace-nowrap"
>
<button
class="text-left hover:underline"
onclick={() => sortBy(c)}
title={c.help ?? `Sort by ${c.name}`}
>
{c.name}{#if c.pk}<span class="ml-1 text-[10px] text-amber-500">PK</span
>{/if}{#if sorted}<span aria-hidden="true"
>{params.get('dir') === 'desc' ? ' ↓' : ' ↑'}</span
>{/if}
</button>
<div class="text-[11px] font-normal text-[var(--site-muted)]">
{shortType(c.type)}{c.nullable ? '' : ' · req'}{c.fk
? ` → ${c.fk.table}`
: ''}{c.readOnly ? ' · auto' : ''}
</div>
</th>
{/each}
{#if editable && t.canDelete}<th class="w-8 border-b border-[var(--site-border)]"
></th>{/if}
</tr>
</thead>
<tbody>
{#each rows as row, r (keyOf(row) + r)}
<tr
class={[
'hover:bg-[var(--site-hover)]',
selected[keyOf(row)] && 'bg-[var(--site-hover)]'
]}
>
{#if t.primaryKey.length}
<td class="border-b border-[var(--site-border)] px-2">
<input
type="checkbox"
aria-label="Select row"
bind:checked={selected[keyOf(row)]}
/>
</td>
{/if}
{#each cols as c, ci (c.name)}
{@const v = row[c.name]}
{#if editing && editing.r === r && editing.c === ci}
<td class="min-w-48 border-b border-[var(--site-border)] p-1 align-top">
<FieldInput
column={c}
bind:value={draft}
lookupUrl={`${base}/lookup`}
autofocus
compact
onsave={saveEdit}
oncancel={cancelEdit}
/>
<div class="mt-1 text-[11px] text-[var(--site-muted)]">
Enter{c.kind === 'json' || c.uiType === 'longtext' ? ' + Ctrl' : ''} save · Esc cancel
· Tab next
</div>
</td>
{:else}
<td
data-cell="{r}:{ci}"
tabindex={active.r === r && active.c === ci ? 0 : -1}
class={[
'max-w-96 cursor-default truncate border-b border-[var(--site-border)] px-2 py-1.5 focus:outline-2 focus:-outline-offset-2 focus:outline-[var(--site-text)]',
(c.kind === 'number' || c.uiType === 'cents') && 'text-right tabular-nums',
editable && !c.readOnly && t.canUpdate && 'cursor-text'
]}
title={v === null
? 'null'
: typeof v === 'object'
? JSON.stringify(v)
: String(v)}
onclick={() => (active = { r, c: ci })}
ondblclick={() => startEdit(r, ci)}
onkeydown={(e) => cellKey(e, r, ci)}
>
{#if v === null}<span class="text-xs text-[var(--site-muted)] opacity-60"
>null</span
>
{:else if c.fk && data.fkLabels[c.name]?.[String(v)]}<span title={String(v)}
>{data.fkLabels[c.name][String(v)]}</span
>
{:else if (c.uiType === 'url' || c.uiType === 'image') && typeof v === 'string' && /^https?:\/\//.test(v)}
<a
href={v}
target="_blank"
rel="noopener noreferrer"
class="underline underline-offset-2">{display(c, v)}</a
>
{:else}{display(c, v)}{/if}
</td>
{/if}
{/each}
{#if editable && t.canDelete}
<td class="border-b border-[var(--site-border)] px-1 text-center">
<button
class="rounded px-1.5 text-[var(--site-muted)] hover:bg-red-500/10 hover:text-red-500"
title="Delete row"
aria-label="Delete row"
onclick={() => askDelete([row])}>✕</button
>
</td>
{/if}
</tr>
{:else}
<tr
><td colspan={cols.length + 2} class="px-3 py-8 text-center text-[var(--site-muted)]"
>no rows</td
></tr
>
{/each}
</tbody>
</table>
</div>
<div class="mt-3 flex flex-wrap items-center gap-3 text-sm">
<Button
size="sm"
variant="outline"
disabled={pageNo <= 1}
onclick={() => navigate({ page: String(pageNo - 1) }, true)}>← prev</Button
>
<span>page {pageNo}{pages ? ` of ${pages.toLocaleString('en-GB')}` : ''}</span>
<Button
size="sm"
variant="outline"
disabled={pages !== null ? pageNo >= pages : rows.length < limit}
onclick={() => navigate({ page: String(pageNo + 1) }, true)}>next →</Button
>
<label class="ml-2 flex items-center gap-1 text-[var(--site-muted)]">
rows per page
<select
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-1 py-0.5"
value={String(limit)}
onchange={(e) => navigate({ limit: e.currentTarget.value })}
>
{#each [25, 50, 100, 250, 500] as n (n)}<option value={String(n)}>{n}</option>{/each}
</select>
</label>
<span class="ml-auto text-xs text-[var(--site-muted)]"
>↑↓←→ move · Enter edit · Space select · / search · n new row</span
>
</div>
</div>
<AlertDialog.Root bind:open={unlockOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Unlock editing {t.schema}.{t.name}?</AlertDialog.Title>
<AlertDialog.Description>
This is an app table, not a list. Edits change what the {t.schema} pages show and could break
them. Every change is written to the edit log. Editing stays unlocked for this table until you
lock it again or close the tab.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() => {
setUnlocked(true);
unlockOpen = false;
}}>unlock editing</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title
>Delete {deleteTargets.length} row{deleteTargets.length === 1
? ''
: 's'}?</AlertDialog.Title
>
<AlertDialog.Description>
From {t.schema}.{t.name}. This can't be undone here (the old values are kept in the edit
log).
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>cancel</AlertDialog.Cancel>
<AlertDialog.Action class="bg-red-600 text-white hover:bg-red-700" onclick={confirmDelete}
>delete</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root bind:open={importOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Import {importFile?.name}?</AlertDialog.Title>
<AlertDialog.Description>
Appends every row to {t.schema}.{t.name}. The first line must hold column names; empty cells
use the column default. If any row fails, nothing is imported.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel
onclick={() => {
importFile = null;
if (fileInput) fileInput.value = '';
}}>cancel</AlertDialog.Cancel
>
<AlertDialog.Action onclick={confirmImport}>import</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<Dialog.Root bind:open={newOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<Dialog.Header><Dialog.Title>new row in {t.schema}.{t.name}</Dialog.Title></Dialog.Header>
{#if newError}<p role="alert" class="text-sm text-red-500">{newError}</p>{/if}
<form class="space-y-3" onsubmit={saveNew}>
{#each insertable as c, i (c.name)}
<label class="block text-sm">
<span class="mb-1 flex items-baseline gap-2">
<span class="font-medium">{c.name}</span>
<span class="text-xs text-[var(--site-muted)]"
>{shortType(c.type)}{!c.nullable && !c.hasDefault ? ' · required' : ''}{c.fk
? ` → ${c.fk.table}`
: ''}</span
>
</span>
<FieldInput
column={c}
bind:value={newValues[c.name]}
lookupUrl={`${base}/lookup`}
autofocus={i === 0}
/>
</label>
{/each}
<Button type="submit">add row</Button>
</form>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={colOpen}>
<Dialog.Content>
<Dialog.Header><Dialog.Title>new column</Dialog.Title></Dialog.Header>
{#if colError}<p role="alert" class="text-sm text-red-500">{colError}</p>{/if}
<form class="space-y-3" onsubmit={addColumn}>
<label class="block text-sm"
>name (lowercase, a–z, 0–9, _)
<input
bind:value={colName}
required
pattern="[a-z][a-z0-9_]*"
maxlength="63"
class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5"
/>
</label>
<label class="block text-sm"
>label (optional)
<input
bind:value={colLabel}
maxlength="100"
class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5"
/>
</label>
<label class="block text-sm"
>type
<select
bind:value={colType}
class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5"
>
{#each ['text', 'longtext', 'number', 'cents', 'bool', 'date', 'datetime', 'url', 'image', 'select', 'multiselect', 'json'] as ty (ty)}<option
value={ty}>{ty}</option
>{/each}
</select>
</label>
{#if colType === 'select' || colType === 'multiselect'}
<label class="block text-sm"
>options (comma separated)
<input
bind:value={colOptions}
class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5"
/>
</label>
{/if}
<Button type="submit">add column</Button>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,25 @@
import { json } from '@sveltejs/kit';
import { addListColumn } from '#lib/server/data-editor.js';
import { failure, noStore, ownerTable } from '#lib/server/data-route.js';
import type { RequestHandler } from './$types';
/** Add a column to a runtime list (schema `lists` only, via meta.add_column()). */
export const POST: RequestHandler = async (event) => {
const { table, actor } = await ownerTable(event);
const b = (await event.request.json().catch(() => ({}))) as Record<string, unknown>;
try {
await addListColumn(
table,
{
name: String(b.name ?? '').trim(),
uiType: String(b.uiType ?? ''),
label: typeof b.label === 'string' ? b.label : undefined,
options: Array.isArray(b.options) ? b.options.map(String) : undefined
},
actor
);
return json({ ok: true }, { status: 201, headers: noStore });
} catch (e) {
return failure(e);
}
};

View File

@@ -0,0 +1,26 @@
import { exportRows } from '#lib/server/data-editor.js';
import { toCsv } from '#lib/server/data-policy.js';
import { failure, noStore, ownerTable, rowQuery } from '#lib/server/data-route.js';
import type { RequestHandler } from './$types';
/** CSV of every row matching the current search/filters/sort (header = column names). */
export const GET: RequestHandler = async (event) => {
const { table } = await ownerTable(event);
try {
const rows = await exportRows(table, { ...rowQuery(event.url), limit: undefined, offset: 0 });
const csv = toCsv(
table.columns.map((c) => c.name),
rows
);
const stamp = new Date().toISOString().slice(0, 10);
return new Response(csv, {
headers: {
...noStore,
'content-type': 'text/csv; charset=utf-8',
'content-disposition': `attachment; filename="${table.schema}.${table.name}-${stamp}.csv"`
}
});
} catch (e) {
return failure(e);
}
};

View File

@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import { DataError, importRows } from '#lib/server/data-editor.js';
import { parseCsv } from '#lib/server/data-policy.js';
import { failure, noStore, ownerTable, unlocked } from '#lib/server/data-route.js';
import type { RequestHandler } from './$types';
/** Append rows from a CSV body (text/csv, at most 10 MB). All rows or none. */
export const POST: RequestHandler = async (event) => {
const { table, actor } = await ownerTable(event);
try {
const length = Number(event.request.headers.get('content-length') ?? 0);
if (length > 10 * 1024 * 1024) throw new DataError(413, 'CSV files are limited to 10 MB.');
const text = await event.request.text();
if (text.length > 10 * 1024 * 1024) throw new DataError(413, 'CSV files are limited to 10 MB.');
let rows: string[][];
try {
rows = parseCsv(text);
} catch (e) {
throw new DataError(400, (e as Error).message);
}
const inserted = await importRows(table, rows, unlocked(event.request), actor);
return json({ inserted }, { headers: noStore });
} catch (e) {
return failure(e);
}
};

View File

@@ -0,0 +1,13 @@
import { json } from '@sveltejs/kit';
import { fkOptions } from '#lib/server/data-editor.js';
import { noStore, ownerTable } from '#lib/server/data-route.js';
import type { RequestHandler } from './$types';
/** Options for a foreign-key column: rows of the referenced table as {value, label}. */
export const GET: RequestHandler = async (event) => {
const { table } = await ownerTable(event);
const column = table.columns.find((c) => c.name === event.url.searchParams.get('column'));
if (!column?.fk) return json({ options: [] }, { headers: noStore });
const q = (event.url.searchParams.get('q') ?? '').slice(0, 100);
return json({ options: await fkOptions(column, { q }) }, { headers: noStore });
};

View File

@@ -0,0 +1,62 @@
import { json } from '@sveltejs/kit';
import { deleteRows, insertRow, readRows, updateRow } from '#lib/server/data-editor.js';
import { failure, noStore, ownerTable, rowQuery, unlocked } from '#lib/server/data-route.js';
import type { RequestHandler } from './$types';
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);
async function body(request: Request): Promise<Record<string, unknown>> {
const b = await request.json().catch(() => null);
return isObject(b) ? b : {};
}
export const GET: RequestHandler = async (event) => {
const { table } = await ownerTable(event);
try {
return json(await readRows(table, rowQuery(event.url)), { headers: noStore });
} catch (e) {
return failure(e);
}
};
export const POST: RequestHandler = async (event) => {
const { table, actor } = await ownerTable(event);
const b = await body(event.request);
try {
const row = await insertRow(
table,
isObject(b.values) ? b.values : {},
unlocked(event.request),
actor
);
return json({ row }, { status: 201, headers: noStore });
} catch (e) {
return failure(e);
}
};
export const PATCH: RequestHandler = async (event) => {
const { table, actor } = await ownerTable(event);
const b = await body(event.request);
try {
if (!isObject(b.pk) || !isObject(b.values))
return json({ message: 'Bad request.' }, { status: 400 });
const row = await updateRow(table, b.pk, b.values, unlocked(event.request), actor);
return json({ row }, { headers: noStore });
} catch (e) {
return failure(e);
}
};
export const DELETE: RequestHandler = async (event) => {
const { table, actor } = await ownerTable(event);
const b = await body(event.request);
try {
const pks = Array.isArray(b.pks) ? b.pks.filter(isObject) : [];
const deleted = await deleteRows(table, pks, unlocked(event.request), actor);
return json({ deleted }, { headers: noStore });
} catch (e) {
return failure(e);
}
};

View File

@@ -14,9 +14,9 @@
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>
<h2 class="pt-2 text-lg font-semibold">Bank data (Money)</h2>
<p>
The money tracker is a private tool used only by the site owner. With the account holders'
Money (/trackers/money) 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

View File

@@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
// Money moved from /projects/money-tracker to /trackers/money. Old links, and the Enable Banking
// redirect URL registered as /projects/money-tracker/bank-callback, keep working: 308 keeps the
// method, the subpath and the query string (?range=, ?code=&state=).
export const fallback: RequestHandler = ({ params, url }) => {
redirect(308, `/trackers/money${params.rest ? `/${params.rest}` : ''}${url.search}`);
};
export const GET = fallback;

View File

@@ -0,0 +1,30 @@
import { isRedirect } from '@sveltejs/kit';
import { describe, expect, it } from 'vitest';
import { fallback } from './+server';
function target(path: string, rest: string) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallback({ params: { rest }, url: new URL(`https://stanrunge.dev${path}`) } as any);
} catch (thrown) {
if (isRedirect(thrown)) return [thrown.status, thrown.location];
throw thrown;
}
return null;
}
describe('old money tracker URLs', () => {
it('redirects to /trackers/money, keeping subpath and query', () => {
expect(target('/projects/money-tracker?range=3y', '')).toEqual([
308,
'/trackers/money?range=3y'
]);
expect(target('/projects/money-tracker/transactions?page=2', 'transactions')).toEqual([
308,
'/trackers/money/transactions?page=2'
]);
expect(
target('/projects/money-tracker/bank-callback?state=s&code=abc-123', 'bank-callback')
).toEqual([308, '/trackers/money/bank-callback?state=s&code=abc-123']);
});
});

View File

@@ -22,10 +22,11 @@
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>
<h2 class="pt-2 text-lg font-semibold">Money</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.
Money (/trackers/money) 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>

View File

@@ -14,6 +14,14 @@
type TextSelection,
type TocItem
} from '#lib/books/reader/types.js';
import {
defaultLayouts,
loadLayout,
saveLayout,
stepZoom,
wheelZoom,
type ViewLayout
} from '#lib/books/reader/layout.js';
import {
highlightColors,
highlightCss,
@@ -32,12 +40,19 @@
import MessageSquarePlus from 'lucide-svelte/icons/message-square-plus';
import Minus from 'lucide-svelte/icons/minus';
import Plus from 'lucide-svelte/icons/plus';
import ZoomIn from 'lucide-svelte/icons/zoom-in';
import ZoomOut from 'lucide-svelte/icons/zoom-out';
import Columns2 from 'lucide-svelte/icons/columns-2';
import RectangleVertical from 'lucide-svelte/icons/rectangle-vertical';
import ScrollText from 'lucide-svelte/icons/scroll-text';
import BookOpen from 'lucide-svelte/icons/book-open';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const SETTINGS_KEY = 'books-reader-settings';
const isPdf = $derived(data.file.engine === 'pdf');
const kind = $derived(isPdf ? 'pdf' : 'foliate');
const progressUrl = $derived(
resolve('/trackers/books/[id=integer]/progress', { id: String(data.book.id) })
);
@@ -48,6 +63,10 @@
let engine = $state<ReaderEngine>();
let settings = $state<ReaderSettings>({ ...defaultSettings });
// Zoom, 1/2 pages and scroll vs paged: remembered per format, zoom also per book.
let layout = $state<ViewLayout>({ ...defaultLayouts.foliate });
let fixedLayout = $state(false);
let bookEl = $state<HTMLElement>();
let siteDark = $state(true);
// Seeded from the load; the reader adds/edits/deletes in place (writable derived).
let highlights = $derived<Highlight[]>(data.highlights);
@@ -80,12 +99,21 @@
} catch {
// ignore broken settings
}
layout = loadLayout(localStorage, kind, data.book.id);
siteDark = !getComputedStyle(document.documentElement).colorScheme.includes('light');
// Ctrl+wheel and trackpad pinch zoom the book, not the whole page.
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
setZoom(wheelZoom(layout.zoom, e.deltaY, kind));
};
bookEl?.addEventListener('wheel', onWheel, { passive: false });
const onHide = () => flush(true);
const onVisibility = () => document.visibilityState === 'hidden' && flush(true);
window.addEventListener('pagehide', onHide);
document.addEventListener('visibilitychange', onVisibility);
return () => {
bookEl?.removeEventListener('wheel', onWheel);
window.removeEventListener('pagehide', onHide);
document.removeEventListener('visibilitychange', onVisibility);
flush(true);
@@ -97,6 +125,16 @@
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
function setLayout(patch: Partial<ViewLayout>) {
layout = { ...layout, ...patch };
saveLayout(localStorage, kind, data.book.id, layout);
}
const setZoom = (zoom: number) => setLayout({ zoom });
const zoomBy = (dir: 1 | -1) => setZoom(stepZoom(layout.zoom, dir, kind));
// Spreads only apply to paged reflowable text; PDFs stack spreads when scrolling too.
const spreadAvailable = $derived(isPdf || (!fixedLayout && layout.mode === 'paged'));
const layoutAdjustable = $derived(isPdf || !fixedLayout);
// ---- progress saving: debounced while reading, flushed on leave ----
let sessionId: number | null = null;
let pending: Relocation | null = null;
@@ -213,6 +251,14 @@
function onkey(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('input, textarea, select, [contenteditable="true"]')) return;
if ((e.metaKey || e.ctrlKey) && !e.altKey) {
if (e.key === '+' || e.key === '=') zoomBy(1);
else if (e.key === '-' || e.key === '_') zoomBy(-1);
else if (e.key === '0') setZoom(1);
else return;
e.preventDefault();
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) {
case 'ArrowRight':
@@ -226,7 +272,7 @@
engine?.prev();
break;
case ' ':
if (isPdf) return;
if (isPdf && layout.mode === 'scroll') return; // native scrolling
if (e.shiftKey) engine?.prev();
else engine?.next();
break;
@@ -265,6 +311,55 @@
<p class="truncate text-sm font-medium">{data.book.title}</p>
<p class="truncate text-xs opacity-60">{tocLabel ?? data.book.authors.join(', ')}</p>
</div>
{#if layoutAdjustable}
<div class="view-tools">
<button
type="button"
class="icon-btn"
aria-label="Zoom out"
title="Zoom out (Ctrl −)"
onclick={() => zoomBy(-1)}><ZoomOut size={18} /></button
>
<button
type="button"
class="zoom-level"
title="Reset zoom (Ctrl 0)"
aria-label="Reset zoom"
onclick={() => setZoom(1)}>{Math.round(layout.zoom * 100)}%</button
>
<button
type="button"
class="icon-btn"
aria-label="Zoom in"
title="Zoom in (Ctrl +)"
onclick={() => zoomBy(1)}><ZoomIn size={18} /></button
>
{#if spreadAvailable}
<button
type="button"
class="icon-btn"
aria-label={layout.spread === 2 ? 'Show one page' : 'Show two pages'}
title={layout.spread === 2 ? 'Two pages (switch to one)' : 'One page (switch to two)'}
onclick={() => setLayout({ spread: layout.spread === 2 ? 1 : 2 })}
>{#if layout.spread === 2}<Columns2 size={18} />{:else}<RectangleVertical
size={18}
/>{/if}</button
>
{/if}
<button
type="button"
class="icon-btn"
aria-label={layout.mode === 'scroll' ? 'Switch to pages' : 'Switch to scrolling'}
title={layout.mode === 'scroll'
? 'Scrolling (switch to pages)'
: 'Pages (switch to scrolling)'}
onclick={() => setLayout({ mode: layout.mode === 'scroll' ? 'paged' : 'scroll' })}
>{#if layout.mode === 'scroll'}<ScrollText size={18} />{:else}<BookOpen
size={18}
/>{/if}</button
>
</div>
{/if}
<button
type="button"
class={['icon-btn', panel === 'toc' && 'on']}
@@ -289,7 +384,7 @@
</header>
<div class="stage">
<main class="book">
<main class="book" bind:this={bookEl}>
{#if isPdf}
<PdfReader
bind:this={engine}
@@ -299,9 +394,12 @@
startPercent={data.resume.percent}
{highlights}
{settings}
{layout}
{theme}
onready={(t) => {
onzoom={setZoom}
onready={(t, info) => {
toc = t;
fixedLayout = info?.fixedLayout ?? false;
ready = true;
}}
{onrelocate}
@@ -320,9 +418,12 @@
startPercent={data.resume.percent}
{highlights}
{settings}
{layout}
{theme}
onready={(t) => {
onzoom={setZoom}
onready={(t, info) => {
toc = t;
fixedLayout = info?.fixedLayout ?? false;
ready = true;
}}
{onrelocate}
@@ -332,20 +433,20 @@
onloading={(f) => (loading = f)}
onerror={(m) => (failure = m)}
/>
{#if ready && settings.flow === 'paginated'}
<button
type="button"
class="turn left"
aria-label="Previous page"
onclick={() => engine?.prev()}><ChevronLeft size={22} /></button
>
<button
type="button"
class="turn right"
aria-label="Next page"
onclick={() => engine?.next()}><ChevronRight size={22} /></button
>
{/if}
{/if}
{#if ready && (layout.mode === 'paged' || fixedLayout)}
<button
type="button"
class="turn left"
aria-label={layout.spread === 2 ? 'Previous pages' : 'Previous page'}
onclick={() => engine?.prev()}><ChevronLeft size={22} /></button
>
<button
type="button"
class="turn right"
aria-label={layout.spread === 2 ? 'Next pages' : 'Next page'}
onclick={() => engine?.next()}><ChevronRight size={22} /></button
>
{/if}
{#if failure}
@@ -444,50 +545,61 @@
{/each}
</div>
</fieldset>
{#if isPdf}
{#if layoutAdjustable}
<fieldset>
<legend>Zoom</legend>
<legend>{isPdf ? 'Zoom' : 'Zoom (text size)'}</legend>
<div class="stepper">
<button
type="button"
aria-label="Zoom out"
onclick={() =>
updateSettings({
pdfZoom: Math.max(0.5, +(settings.pdfZoom - 0.1).toFixed(2))
})}><Minus size={14} /></button
>
<span>{Math.round(settings.pdfZoom * 100)}%</span>
<button
type="button"
aria-label="Zoom in"
onclick={() =>
updateSettings({
pdfZoom: Math.min(3, +(settings.pdfZoom + 0.1).toFixed(2))
})}><Plus size={14} /></button
>
</div>
</fieldset>
{:else}
<fieldset>
<legend>Text size</legend>
<div class="stepper">
<button
type="button"
aria-label="Smaller text"
onclick={() =>
updateSettings({ fontSize: Math.max(70, settings.fontSize - 10) })}
<button type="button" aria-label="Zoom out" onclick={() => zoomBy(-1)}
><Minus size={14} /></button
>
<span>{settings.fontSize}%</span>
<button
type="button"
aria-label="Larger text"
onclick={() =>
updateSettings({ fontSize: Math.min(220, settings.fontSize + 10) })}
class="stepper-value"
title="Reset zoom"
onclick={() => setZoom(1)}>{Math.round(layout.zoom * 100)}%</button
>
<button type="button" aria-label="Zoom in" onclick={() => zoomBy(1)}
><Plus size={14} /></button
>
</div>
</fieldset>
<fieldset>
<legend>Reading</legend>
<div class="seg">
<button
type="button"
class={layout.mode === 'scroll' && 'on'}
onclick={() => setLayout({ mode: 'scroll' })}>Scroll</button
>
<button
type="button"
class={layout.mode === 'paged' && 'on'}
onclick={() => setLayout({ mode: 'paged' })}>Pages</button
>
</div>
</fieldset>
<fieldset>
<legend>Pages side by side</legend>
<div class="seg">
<button
type="button"
class={layout.spread === 1 && 'on'}
onclick={() => setLayout({ spread: 1 })}>One</button
>
<button
type="button"
class={layout.spread === 2 && 'on'}
onclick={() => setLayout({ spread: 2 })}>Two</button
>
</div>
{#if !spreadAvailable}
<p class="hint">Two pages apply in Pages mode; scrolling text is one column.</p>
{:else if isPdf}
<p class="hint">The cover stays on its own; narrow screens show one page.</p>
{/if}
</fieldset>
{/if}
{#if !isPdf}
<fieldset>
<legend>Font</legend>
<div class="seg">
@@ -513,24 +625,10 @@
{/each}
</div>
</fieldset>
<fieldset>
<legend>Layout</legend>
<div class="seg">
<button
type="button"
class={settings.flow === 'paginated' && 'on'}
onclick={() => updateSettings({ flow: 'paginated' })}>Pages</button
>
<button
type="button"
class={settings.flow === 'scrolled' && 'on'}
onclick={() => updateSettings({ flow: 'scrolled' })}>Scroll</button
>
</div>
</fieldset>
{/if}
<p class="text-xs opacity-60">
Keys: ← → or h l to turn pages, t for contents, Esc to close.
Keys: ← → / PgUp PgDn or h l to turn pages, Ctrl + / − / 0 to zoom (or Ctrl +
scroll, pinch), t for contents, Esc to close.
</p>
</div>
{/if}
@@ -640,7 +738,8 @@
}}
/>
<span class="w-24 text-right text-xs tabular-nums opacity-70">
{#if location?.page && location.pageCount}{location.page} / {location.pageCount}{:else if saveState === 'saving'}saving…{:else if saveState === 'error'}not
{#if location?.page && location.pageCount}{location.page}{#if location.pageEnd}–{location.pageEnd}{/if}
/ {location.pageCount}{:else if saveState === 'saving'}saving…{:else if saveState === 'error'}not
saved{:else if saveState === 'saved'}saved{/if}
</span>
</footer>
@@ -900,12 +999,45 @@
.seg button:hover {
background: var(--r-soft);
}
.stepper span {
.stepper .stepper-value {
flex: 1;
text-align: center;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.stepper .stepper-value {
width: auto;
}
.hint {
margin-top: 0.35rem;
font-size: 0.7rem;
opacity: 0.55;
}
.view-tools {
display: none;
align-items: center;
gap: 0.125rem;
padding-right: 0.375rem;
margin-right: 0.125rem;
border-right: 1px solid var(--r-line);
}
@media (min-width: 640px) {
.view-tools {
display: flex;
}
}
.zoom-level {
min-width: 3rem;
height: 2.25rem;
border-radius: 0.5rem;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
opacity: 0.75;
}
.zoom-level:hover {
opacity: 1;
background: var(--r-soft);
}
.seg button {
flex: 1;
height: 2.25rem;

View File

@@ -13,7 +13,9 @@ import {
import { requireAdmin } from '#lib/server/authorization.js';
import {
type ImportedTransaction,
accountNumberKey,
cadence,
dropInternalTransfers,
cents,
invalid,
optionalText,
@@ -24,12 +26,12 @@ import {
utilityKind,
validDate
} from '#lib/server/money-tracker.js';
import { and, asc, desc, eq, gte, inArray } from 'drizzle-orm';
import { and, asc, desc, eq, gte, inArray, isNull, like, sql } from 'drizzle-orm';
import { error, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
if (!event.locals.user) redirect(303, '/login?returnTo=/projects/money-tracker');
if (!event.locals.user) redirect(303, '/login?returnTo=/trackers/money');
const user = requireAdmin(event);
const userId = user.id;
const [
@@ -125,6 +127,7 @@ export const actions: Actions = {
const name = textField(form, 'name', 80);
const openingBalanceCents = cents(form.get('openingBalance'));
const kind = String(form.get('kind') ?? 'spendable');
const includeInTotal = form.get('includeInTotal') === 'on';
const colorRaw = String(form.get('color') ?? '').trim();
const color = colorRaw ? colorRaw.toLowerCase() : null;
if (!name || openingBalanceCents == null || !['spendable', 'investment'].includes(kind))
@@ -135,14 +138,14 @@ export const actions: Actions = {
if (id) {
const [row] = await db
.update(moneyAccounts)
.set({ name, openingBalanceCents, kind, color })
.set({ name, openingBalanceCents, kind, includeInTotal, color })
.where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, user.id)))
.returning({ id: moneyAccounts.id });
if (!row) error(404, 'Account not found');
} else
await db
.insert(moneyAccounts)
.values({ userId: user.id, name, openingBalanceCents, kind, color });
.values({ userId: user.id, name, openingBalanceCents, kind, includeInTotal, color });
} catch (cause) {
if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505')
return invalid('An account with that name already exists.');
@@ -457,19 +460,34 @@ export const actions: Actions = {
importTransactions: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const file = form.get('file');
if (!(file instanceof File) || !file.size || file.size > 2_000_000)
return invalid('Choose a CSV or TSV file under 2 MB.');
const files = form
.getAll('file')
.filter((file): file is File => file instanceof File && file.size > 0);
if (!files.length || files.length > 10 || files.some((file) => file.size > 2_000_000))
return invalid('Choose up to 10 CSV or TSV files, each 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[];
const parsed: ImportedTransaction[] = [];
try {
parsed = parseTransactionImport(await file.text(), chosen?.name);
for (const file of files)
parsed.push(...parseTransactionImport(await file.text(), chosen?.name));
} catch (cause) {
return invalid(cause instanceof Error ? cause.message : 'Invalid CSV.');
}
parsed.sort((a, b) => a.date.localeCompare(b.date));
// Bank statements that name their account go to the tracker account with that IBAN.
const byNumber = new Map(
accounts
.filter((account) => account.iban)
.map((account) => [accountNumberKey(account.iban), account])
);
if (!chosen)
for (const row of parsed) {
const match = row.sourceAccount && byNumber.get(accountNumberKey(row.sourceAccount));
if (match) row.account = match.name;
}
const result = await db.transaction(async (tx) => {
const ids = new Map(accounts.map((account) => [account.name.toLowerCase(), account.id]));
for (const name of [...new Set(parsed.map((row) => row.account))]) {
@@ -485,23 +503,96 @@ export const actions: Actions = {
accountId: ids.get(row.account.toLowerCase())!
}));
const accountIds = [...new Set(rows.map((row) => row.accountId))];
const existing = await tx
// Several bank accounts can feed one tracker account (Knab current + savings): transfers
// between them are internal and dropped on both sides. Interest and the rest stay.
const statementRows = await tx
.select({
id: moneyTransactions.id,
accountId: moneyTransactions.accountId,
date: moneyTransactions.date,
amountCents: moneyTransactions.amountCents,
externalId: moneyTransactions.externalId
externalId: moneyTransactions.externalId,
raw: moneyTransactions.raw
})
.from(moneyTransactions)
.where(
and(
eq(moneyTransactions.userId, user.id),
inArray(moneyTransactions.accountId, accountIds),
gte(moneyTransactions.date, addDays(rows[0].date, -1))
like(moneyTransactions.externalId, 'knab:%')
)
);
const own = new Map<number, Set<string>>();
const addOwn = (accountId: number, number: string | null | undefined) => {
if (!number) return;
own.set(accountId, (own.get(accountId) ?? new Set()).add(accountNumberKey(number)));
};
for (const account of accounts)
if (accountIds.includes(account.id)) addOwn(account.id, account.iban);
for (const row of rows) addOwn(row.accountId, row.sourceAccount);
for (const row of statementRows) addOwn(row.accountId, row.externalId?.split(':')[1]);
const kept: typeof rows = [];
let internal = 0;
for (const accountId of accountIds) {
const split = dropInternalTransfers(
rows.filter((row) => row.accountId === accountId),
own.get(accountId) ?? []
);
kept.push(...split.kept);
internal += split.internal.length;
}
kept.sort((a, b) => a.date.localeCompare(b.date));
// Legs imported before the other account's export was known.
const stale = statementRows
.filter((row) => {
const other = accountNumberKey(
(row.raw as { counterpartyAccount?: string } | null)?.counterpartyAccount
);
return other && own.get(row.accountId)?.has(other);
})
.map((row) => row.id);
if (stale.length)
await tx.delete(moneyTransactions).where(inArray(moneyTransactions.id, stale));
internal += stale.length;
if (!kept.length) return { imported: 0, duplicates: 0, internal, replaced: 0 };
const existing = await tx
.select({
id: moneyTransactions.id,
accountId: moneyTransactions.accountId,
date: moneyTransactions.date,
amountCents: moneyTransactions.amountCents,
externalId: moneyTransactions.externalId,
description: moneyTransactions.description
})
.from(moneyTransactions)
.where(
and(
eq(moneyTransactions.userId, user.id),
inArray(moneyTransactions.accountId, accountIds),
gte(moneyTransactions.date, addDays(kept[0].date, -1))
)
);
const { insert, duplicates, attach, replace } = planImport(kept, existing);
// Hand-entered twins keep their description but get the import id, so re-imports skip them.
for (const { id, row } of attach)
await tx
.update(moneyTransactions)
.set({
externalId: row.externalId,
counterparty: sql`coalesce(${moneyTransactions.counterparty}, ${row.counterparty ?? null})`
})
.where(and(eq(moneyTransactions.id, id), isNull(moneyTransactions.externalId)));
// Hand entries that were the sum of several imported payments are replaced by them.
if (replace.length)
await tx.delete(moneyTransactions).where(
and(
inArray(
moneyTransactions.id,
replace.map((item) => item.id)
),
isNull(moneyTransactions.externalId)
)
);
const { insert, duplicates } = planImport(rows, existing);
for (let i = 0; i < insert.length; i += 500) {
await tx
.insert(moneyTransactions)
@@ -513,12 +604,19 @@ export const actions: Actions = {
description: row.description,
amountCents: row.amountCents,
externalId: row.externalId,
counterparty: row.counterparty ?? null,
raw: row.sourceAccount
? {
account: row.sourceAccount,
counterpartyAccount: row.counterpartyAccount ?? null
}
: null,
source: 'csv'
}))
)
.onConflictDoNothing();
}
return { imported: insert.length, duplicates };
return { imported: insert.length, duplicates, internal, replaced: replace.length };
});
return result;
}

View File

@@ -17,14 +17,21 @@
import Download from 'lucide-svelte/icons/download';
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
import { sortWishlist, tierClass } from '#lib/money-tracker/wishlist.js';
import { Badge } from '#lib/components/ui/badge/index.js';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import * as Tabs from '#lib/components/ui/tabs/index.js';
import {
accountBalances,
accountColor,
clipSeries,
type HistoryRange,
historyStart,
parseHistoryRange,
monthlyCents,
type TimelineView,
utilityEstimate,
@@ -76,9 +83,13 @@
let actualTransactions = $derived(data.transactions.filter((item) => item.date <= data.today));
let futureTransactions = $derived(data.transactions.filter((item) => item.date > data.today));
let balances = $derived(accountBalances(data.accounts, actualTransactions));
let total = $derived([...balances.values()].reduce((sum, value) => sum + value, 0));
let totalAccounts = $derived(data.accounts.filter((account) => account.includeInTotal !== false));
let totalIds = $derived(new Set(totalAccounts.map((account) => account.id)));
let total = $derived(
totalAccounts.reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
);
let invested = $derived(
data.accounts
totalAccounts
.filter((account) => account.kind === 'investment')
.reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
);
@@ -87,7 +98,9 @@
);
let monthNet = $derived(
actualTransactions
.filter((item) => item.date.slice(0, 7) === data.today.slice(0, 7))
.filter(
(item) => item.date.slice(0, 7) === data.today.slice(0, 7) && totalIds.has(item.accountId)
)
.reduce((sum, item) => sum + item.amountCents, 0)
);
let monthlyInsurance = $derived(
@@ -132,8 +145,25 @@
{ id: 'accounts', label: 'By account' }
];
let timelineView = $state<TimelineView>('total');
const historyRanges: { id: HistoryRange; label: string }[] = [
{ id: '1y', label: '1 year' },
{ id: '3y', label: '3 years' },
{ id: 'all', label: 'All' }
];
// The range lives in ?range= so it survives reloads; 1y is the default.
let historyRange = $state<HistoryRange>(parseHistoryRange(page.url.searchParams.get('range')));
function setHistoryRange(value: string) {
historyRange = parseHistoryRange(value);
const url = new URL(window.location.href);
url.searchParams.set('range', historyRange);
// Shallow routing: rewrites the current history entry without a load, reload or scroll.
replaceState(url, page.state);
}
let timeline = $derived(
wealthTimeline(data.accounts, actualTransactions, projection, data.today, timelineView)
clipSeries(
wealthTimeline(data.accounts, actualTransactions, projection, data.today, timelineView),
historyStart(historyRange, data.today)
)
);
let projectionEnd = $derived(projection.map((line) => line.points.at(-1)?.value ?? 0));
let wishTiers = $derived(
@@ -145,6 +175,7 @@
count: data.wishlist.filter((item) => item.priority >= tier).length
}))
);
let sortedWishlist = $derived(sortWishlist(data.wishlist));
let unpricedWishes = $derived(
data.wishlist.filter((item) => item.estimatedCents === null).length
);
@@ -221,7 +252,7 @@
);
const link = document.createElement('a');
link.href = url;
link.download = 'money-tracker-transactions.csv';
link.download = 'money-transactions.csv';
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
@@ -247,7 +278,7 @@
<header class="app-header">
<div>
<p class="muted">Personal finance</p>
<h1>Money tracker</h1>
<h1>Money</h1>
</div>
<div class="header-actions">
<Button variant="outline" onclick={() => open('import')}
@@ -258,7 +289,7 @@
<section class="stat-strip" aria-label="Summary">
<div class="stat net">
<p class="eyebrow">Net worth · {data.accounts.length} accounts</p>
<p class="eyebrow">Net worth · {totalAccounts.length} accounts</p>
<p class="stat-value">{@render signed(total)}</p>
</div>
<div class="stat">
@@ -301,17 +332,27 @@
>Edit projection</Button
>
</div>
<Tabs.Root bind:value={timelineView} class="mb-3"
><Tabs.List
>{#each timelineViews as item (item.id)}<Tabs.Trigger value={item.id}
>{item.label}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
<div class="mb-3 flex flex-wrap gap-2">
<Tabs.Root bind:value={timelineView}
><Tabs.List
>{#each timelineViews as item (item.id)}<Tabs.Trigger value={item.id}
>{item.label}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
<Tabs.Root value={historyRange} onValueChange={setHistoryRange} aria-label="History range"
><Tabs.List
>{#each historyRanges as item (item.id)}<Tabs.Trigger value={item.id}
>{item.label}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
</div>
<MoneyChart
series={timeline}
title="Actual and projected wealth over time"
marker={{ date: data.today, label: 'Today' }}
grid={historyRange === 'all' ? 'years' : 'months'}
/>
<div class="projection-totals" aria-label="Projected balances at end of period">
<div>
@@ -367,9 +408,10 @@
onclick={() => toggleAccount(account.id)}
>{@render swatch(account.id)}<span
><strong>{account.name}</strong><small class="capitalize"
>{account.kind} · {#if account.bankBalanceAt}bank {amount(
account.bankBalanceCents ?? 0
)}, {new Date(account.bankBalanceAt).toLocaleDateString('en-GB', {
>{account.kind}{#if account.includeInTotal === false}&nbsp;· not in total{/if} · {#if account.bankBalanceAt}bank
{amount(account.bankBalanceCents ?? 0)}, {new Date(
account.bankBalanceAt
).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
hour: '2-digit',
@@ -400,7 +442,7 @@
<p>{filtered.length} of {data.transactions.length} entries</p>
</div>
<div class="header-actions">
<Button variant="outline" href={resolve('/projects/money-tracker/transactions')}
<Button variant="outline" href={resolve('/trackers/money/transactions')}
>All transactions</Button
><Button variant="outline" onclick={exportCsv} disabled={!data.transactions.length}
><Download size={16} /> Export CSV</Button
@@ -662,7 +704,7 @@
>
</div>
<div class="wish-tier-grid">
{#each wishTiers as row (row.tier)}<div>
{#each wishTiers as row (row.tier)}<div class={tierClass(row.tier)}>
<span class="eyebrow">{row.tier === 5 ? 'Tier 5' : `5–${row.tier}`}</span><strong
>{amount(row.value)}</strong
><small>{row.count} {row.count === 1 ? 'item' : 'items'}</small>
@@ -673,9 +715,9 @@
totals.
</p>{/if}
{#if data.wishlist.length}<ul class="item-list scroll">
{#each data.wishlist as row (row.id)}<li>
{#each sortedWishlist as row (row.id)}<li class="wish {tierClass(row.priority)}">
<span class="item-main"
><span class="tier">{row.priority}</span><span
><span class="tier" title="Tier {row.priority}">{row.priority}</span><span
><strong>{row.name}</strong>{#if row.notes}<small>{row.notes}</small>{/if}</span
></span
><strong class="item-amount"
@@ -727,7 +769,7 @@
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="money-dialog block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
<Dialog.Description class="sr-only">Manage your money tracker records.</Dialog.Description>
<Dialog.Description class="sr-only">Manage your money records.</Dialog.Description>
{#if showActionError && form?.message}<p class="form-error" role="alert">{form.message}</p>{/if}
<Dialog.Header class="mb-6 pr-8"
><Dialog.Title>
@@ -769,11 +811,14 @@
{:else if mode === 'import'}
<p class="dialog-copy">
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).
Dates like <code>Fri 1 Aug 2025</code> and amounts like <code>-€1,234.50</code> are supported.
Empty rows are skipped, and entries appear in date order. “Start balance” rows import as transactions,
so leave those accounts’ opening balances at zero. New account names are created automatically.
Rows already imported, or already entered with the same amount within a day, are skipped.
columns, a Revolut statement export (only completed EUR rows are imported, fees included), or
Knab exports. Knab files go to the account with that IBAN; pick an account to merge several Knab
accounts (current + savings) into one, and transfers between them are left out. Dates like
<code>Fri 1 Aug 2025</code>
and amounts like <code>-€1,234.50</code> are supported. Empty rows are skipped, and entries appear
in date order. “Start balance” rows import as transactions, so leave those accounts’ opening balances
at zero. New account names are created automatically. Rows already imported, or already entered
with the same amount within a day, are skipped.
</p>
<form
method="POST"
@@ -785,23 +830,31 @@
showActionError = result.type === 'failure';
if (result.type === 'success') {
dialogOpen = false;
const done = result.data as { imported?: number; duplicates?: number } | undefined;
importNotice = `Imported ${done?.imported ?? 0} transactions; skipped ${done?.duplicates ?? 0} duplicates.`;
const done = result.data as
| { imported?: number; duplicates?: number; internal?: number; replaced?: number }
| undefined;
importNotice =
`Imported ${done?.imported ?? 0} transactions; skipped ${done?.duplicates ?? 0} duplicates` +
(done?.internal ? `, ${done.internal} transfers between your own accounts` : '') +
(done?.replaced
? `; replaced ${done.replaced} hand entries that were sums of payments`
: '') +
'.';
}
}}
>
<label class="field"
>CSV or TSV file<Input
>CSV or TSV files<Input
type="file"
name="file"
multiple
accept=".csv,.tsv,text/csv,text/tab-separated-values"
required
/></label
>
<label class="field"
>Account (for files without an account column, like Revolut statements)<NativeSelect
name="accountId"
bind:value={importAccountId}
>Account (for files without an account column, like Revolut statements, or to merge Knab
accounts)<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
@@ -871,6 +924,10 @@
>Investment accounts are included in total wealth but excluded from the cash reserve.</small
></label
>
<label class="field check-field"
><Checkbox name="includeInTotal" value="on" checked={active.includeInTotal !== false} /> Count
toward total wealth</label
>
<label class="field"
>Color<Input
name="color"

View File

@@ -14,10 +14,7 @@
</script>
<svelte:head
><title>Bank connection · Money tracker</title><meta
name="robots"
content="noindex"
/></svelte:head
><title>Bank connection · Money</title><meta name="robots" content="noindex" /></svelte:head
>
<section class="mx-auto max-w-xl space-y-6 p-6">
@@ -48,6 +45,6 @@
</p>
{/if}
<p>
<a class="underline" href={resolve('/projects/money-tracker')}>Back to the money tracker</a>
<a class="underline" href={resolve('/trackers/money')}>Back to Money</a>
</p>
</section>

View File

@@ -419,6 +419,57 @@
color: var(--site-muted);
font-size: 11px;
}
/* Wish list tier colours: dark theme uses the lighter shade, light theme the deeper one. */
.money-app .tier-5 {
--tier: #fb7185;
}
.money-app .tier-4 {
--tier: #fb923c;
}
.money-app .tier-3 {
--tier: #facc15;
}
.money-app .tier-2 {
--tier: #34d399;
}
.money-app .tier-1 {
--tier: #94a3b8;
}
:root[data-theme='light'] .money-app .tier-5 {
--tier: #e11d48;
}
:root[data-theme='light'] .money-app .tier-4 {
--tier: #ea580c;
}
:root[data-theme='light'] .money-app .tier-3 {
--tier: #ca8a04;
}
:root[data-theme='light'] .money-app .tier-2 {
--tier: #059669;
}
:root[data-theme='light'] .money-app .tier-1 {
--tier: #64748b;
}
.money-app .item-list li.wish:not(.tier-none) {
box-shadow: inset 3px 0 0 var(--tier);
}
.money-app .wish:not(.tier-none) .tier {
border-color: color-mix(in srgb, var(--tier) 55%, transparent);
background: color-mix(in srgb, var(--tier) 16%, transparent);
color: var(--tier);
font-weight: 600;
}
.money-app .wish.tier-5 .tier {
border-color: var(--tier);
background: var(--tier);
color: var(--site-bg);
}
.money-app .wish-tier-grid > div:not(.tier-none) {
border-top: 3px solid var(--tier);
}
.money-app .wish-tier-grid > div:not(.tier-none) .eyebrow {
color: var(--tier);
}
.money-app .mini-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));

View File

@@ -36,7 +36,7 @@
else params.set(key, String(value));
}
const query = params.toString();
return resolve('/projects/money-tracker/transactions') + (query ? '?' + query : '');
return resolve('/trackers/money/transactions') + (query ? '?' + query : '');
}
function submit(event: SubmitEvent) {
event.preventDefault();
@@ -45,7 +45,7 @@
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 : ''), {
goto(resolve('/trackers/money/transactions') + (query ? '?' + query : ''), {
keepFocus: true,
noScroll: true
});
@@ -55,14 +55,14 @@
}
</script>
<svelte:head><title>Transactions · Money tracker</title></svelte:head>
<svelte:head><title>Transactions · Money</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
href={resolve('/trackers/money')}><ArrowLeft size={14} /> Money</a
>
<h1 class="text-2xl font-bold">Transactions</h1>
<p class="text-sm opacity-70">
@@ -138,7 +138,7 @@
<div class="flex items-end gap-2">
<Button type="submit">Apply</Button><Button
variant="ghost"
href={resolve('/projects/money-tracker/transactions')}>Reset</Button
href={resolve('/trackers/money/transactions')}>Reset</Button
>
</div>
</form>

View File

@@ -0,0 +1,11 @@
import { requireOwner } from '#lib/server/owner.js';
import type { LayoutServerLoad } from './$types';
// Owner only: everyone else (logged out, or any other account) gets a plain 404.
// Form actions don't run this load, so +page.server.ts checks again in every action,
// and hooks.server.ts blocks the whole /trackers/tasks prefix as a backstop.
export const load: LayoutServerLoad = (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {};
};

View File

@@ -0,0 +1,261 @@
import { db } from '#lib/server/db/index.js';
import {
TASK_STATUSES,
todoActivity,
todoAreas,
todoGoals,
todoTasks,
type TaskStatus
} from '#lib/server/db/schema/index.js';
import { requireOwner } from '#lib/server/owner.js';
import {
goalInput,
parseLocalMoment,
parseQuickTitle,
statusTimestamps,
taskInput,
todayIn
} from '#lib/server/tasks.js';
import { asc, eq, sql } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const [areas, goals, tasks] = await Promise.all([
db.select().from(todoAreas).orderBy(asc(todoAreas.sortOrder), asc(todoAreas.name)),
db.select().from(todoGoals).orderBy(asc(todoGoals.sortOrder), asc(todoGoals.id)),
db.select().from(todoTasks).orderBy(asc(todoTasks.sortOrder), asc(todoTasks.id))
]);
return {
today: todayIn(),
areas,
goals,
tasks,
seo: { title: 'Tasks', description: 'Private task and goal tracker.', index: false }
};
};
const id = (v: FormDataEntryValue | null) => {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : null;
};
const formObject = (form: FormData) => {
const o: Record<string, unknown> = {};
for (const [k, v] of form) if (typeof v === 'string') o[k] = v;
o.flagged = form.get('flagged') === 'on' || form.get('flagged') === 'true';
return o;
};
const firstIssue = (e: { issues: { path: PropertyKey[]; message: string }[] }) => {
const i = e.issues[0];
return i ? `${i.path.join('.') || 'input'}: ${i.message}` : 'Invalid input';
};
/** Postgres errors arrive wrapped (DrizzleQueryError); report the cause, not the query. */
function dbError(e: unknown): string {
const cause = (e as { cause?: { message?: string; constraint_name?: string } })?.cause;
if (cause?.constraint_name === 'goals_title_key') return 'A goal with that title already exists';
if (cause?.constraint_name) return `Not allowed (${cause.constraint_name})`;
return cause?.message || 'Could not save';
}
const isStatus = (v: unknown): v is TaskStatus =>
typeof v === 'string' && (TASK_STATUSES as readonly string[]).includes(v);
async function nextSortOrder(areaId: number | null) {
const [row] = await db
.select({ max: sql<number>`coalesce(max(${todoTasks.sortOrder}), 0)` })
.from(todoTasks)
.where(areaId === null ? sql`${todoTasks.areaId} IS NULL` : eq(todoTasks.areaId, areaId));
return Number(row?.max ?? 0) + 10;
}
export const actions: Actions = {
/** Quick add: a title (with "!" / "!!!" shortcuts), optional area and due date. */
quick: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const { title, flagged, priority } = parseQuickTitle(String(form.get('title') ?? ''));
const parsed = taskInput.safeParse({
title,
flagged,
priority,
due: form.get('due') ?? '',
area: form.get('areaId') ?? ''
});
if (!parsed.success) return fail(400, { message: firstIssue(parsed.error), quick: true });
const areaId = id(form.get('areaId'));
const [row] = await db
.insert(todoTasks)
.values({
title: parsed.data.title,
flagged: parsed.data.flagged,
priority: parsed.data.priority,
dueDate: parsed.data.due ?? null,
areaId,
sortOrder: await nextSortOrder(areaId),
source: 'manual'
})
.returning({ id: todoTasks.id });
await db.insert(todoActivity).values({ taskId: row.id, kind: 'created', toStatus: 'todo' });
return { added: row.id };
},
/** Inline status change. */
status: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const taskId = id(form.get('id'));
const status = form.get('status');
if (!taskId || !isStatus(status)) return fail(400, { message: 'Invalid status change' });
const [prev] = await db.select().from(todoTasks).where(eq(todoTasks.id, taskId));
if (!prev) return fail(404, { message: 'Task not found' });
if (prev.status === status) return { updated: taskId };
await db.transaction(async (tx) => {
await tx
.update(todoTasks)
.set({ status, ...statusTimestamps(status, prev) })
.where(eq(todoTasks.id, taskId));
await tx.insert(todoActivity).values({
taskId,
kind: 'status',
fromStatus: prev.status,
toStatus: status
});
});
return { updated: taskId };
},
/** Full create/edit from the dialog. */
save: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const taskId = id(form.get('id'));
const parsed = taskInput.safeParse(formObject(form));
if (!parsed.success) return fail(400, { message: firstIssue(parsed.error) });
const t = parsed.data;
const reminderAt = t.reminderAt ? parseLocalMoment(t.reminderAt) : null;
if (t.reminderAt && !reminderAt) return fail(400, { message: 'Invalid reminder' });
const parentId = id(form.get('parentId'));
if (parentId && parentId === taskId)
return fail(400, { message: 'A task cannot be its own parent' });
const values = {
title: t.title,
notes: t.notes ?? null,
status: t.status,
priority: t.priority,
flagged: t.flagged,
dueDate: t.due ?? null,
dueTime: t.dueTime ?? null,
reminderAt,
recurrence: t.recurrence ?? null,
areaId: id(form.get('areaId')),
goalId: id(form.get('goalId')),
parentId,
tags: t.tags
};
try {
if (taskId) {
const [prev] = await db.select().from(todoTasks).where(eq(todoTasks.id, taskId));
if (!prev) return fail(404, { message: 'Task not found' });
await db.transaction(async (tx) => {
await tx
.update(todoTasks)
.set({ ...values, ...statusTimestamps(t.status, prev) })
.where(eq(todoTasks.id, taskId));
await tx.insert(todoActivity).values({
taskId,
kind: prev.status === t.status ? 'edit' : 'status',
fromStatus: prev.status,
toStatus: t.status
});
});
return { saved: taskId };
}
const [row] = await db
.insert(todoTasks)
.values({
...values,
...statusTimestamps(t.status, null),
sortOrder: await nextSortOrder(values.areaId),
source: 'manual'
})
.returning({ id: todoTasks.id });
await db.insert(todoActivity).values({ taskId: row.id, kind: 'created', toStatus: t.status });
return { saved: row.id };
} catch (e) {
return fail(400, { message: dbError(e) });
}
},
delete: async (event) => {
requireOwner(event);
const taskId = id((await event.request.formData()).get('id'));
if (!taskId) return fail(400, { message: 'Invalid task' });
await db.delete(todoTasks).where(eq(todoTasks.id, taskId));
return { deleted: taskId };
},
saveGoal: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const goalId = id(form.get('id'));
const parsed = goalInput.safeParse(formObject(form));
if (!parsed.success) return fail(400, { message: firstIssue(parsed.error) });
const g = parsed.data;
const values = {
title: g.title,
notes: g.notes ?? null,
status: g.status,
areaId: id(form.get('areaId')),
targetDate: g.targetDate ?? null,
metric: g.metric ?? null,
metricUnit: g.metricUnit ?? null,
metricTarget: g.metricTarget ?? null,
metricCurrent: g.metricCurrent ?? null,
flagged: g.flagged
};
try {
if (goalId) {
const [prev] = await db.select().from(todoGoals).where(eq(todoGoals.id, goalId));
if (!prev) return fail(404, { message: 'Goal not found' });
const achievedAt = g.status === 'achieved' ? (prev.achievedAt ?? new Date()) : null;
await db.transaction(async (tx) => {
await tx
.update(todoGoals)
.set({ ...values, achievedAt })
.where(eq(todoGoals.id, goalId));
if (prev.metricCurrent !== values.metricCurrent && values.metricCurrent !== null)
await tx
.insert(todoActivity)
.values({ goalId, kind: 'progress', value: values.metricCurrent });
else if (prev.status !== g.status)
await tx.insert(todoActivity).values({
goalId,
kind: 'status',
fromStatus: prev.status,
toStatus: g.status
});
});
return { savedGoal: goalId };
}
const [row] = await db
.insert(todoGoals)
.values({
...values,
source: 'manual',
achievedAt: g.status === 'achieved' ? new Date() : null
})
.returning({ id: todoGoals.id });
return { savedGoal: row.id };
} catch (e) {
return fail(400, { message: dbError(e) });
}
},
deleteGoal: async (event) => {
requireOwner(event);
const goalId = id((await event.request.formData()).get('id'));
if (!goalId) return fail(400, { message: 'Invalid goal' });
await db.delete(todoGoals).where(eq(todoGoals.id, goalId));
return { deletedGoal: goalId };
}
};

View File

@@ -0,0 +1,920 @@
<script lang="ts">
import { enhance } from '$app/forms';
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 Lock from 'lucide-svelte/icons/lock';
import Flag from 'lucide-svelte/icons/flag';
import Bell from 'lucide-svelte/icons/bell';
import Repeat from 'lucide-svelte/icons/repeat';
import Target from 'lucide-svelte/icons/target';
import Circle from 'lucide-svelte/icons/circle';
import CircleDot from 'lucide-svelte/icons/circle-dot';
import CircleCheck from 'lucide-svelte/icons/circle-check';
import CircleX from 'lucide-svelte/icons/circle-x';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
type Task = (typeof data.tasks)[number];
type Goal = (typeof data.goals)[number];
type Status = Task['status'];
const STATUS_LABEL: Record<Status, string> = {
todo: 'To do',
in_progress: 'In progress',
done: 'Done',
dropped: 'Dropped'
};
const STATUS_ICON = {
todo: Circle,
in_progress: CircleDot,
done: CircleCheck,
dropped: CircleX
} as const;
/** Clicking the status icon: to do → in progress → done → to do. */
const NEXT: Record<Status, Status> = {
todo: 'in_progress',
in_progress: 'done',
done: 'todo',
dropped: 'todo'
};
const PRIORITY = ['None', 'Low', 'Medium', 'High'] as const;
const STATUS_FILTERS = [
['open', 'Open'],
['todo', 'To do'],
['in_progress', 'In progress'],
['done', 'Done'],
['dropped', 'Dropped'],
['all', 'All']
] as const;
// Filters
let statusFilter = $state<'open' | 'all' | Status>('open');
let areaFilter = $state('all');
let priorityFilter = $state<'any' | 'flagged' | 'high'>('any');
let search = $state('');
// Long areas (years of TickTick history) render the first PAGE rows until expanded.
const PAGE = 40;
let expanded = $state<Record<string, boolean>>({});
const isOpen = (t: Task) => t.status === 'todo' || t.status === 'in_progress';
const matches = (t: Task) =>
(statusFilter === 'all' || (statusFilter === 'open' ? isOpen(t) : t.status === statusFilter)) &&
(areaFilter === 'all' ||
(areaFilter === 'none' ? t.areaId === null : t.areaId === Number(areaFilter))) &&
(priorityFilter === 'any' ||
(priorityFilter === 'flagged' ? t.flagged : t.priority >= 2 || t.flagged)) &&
(!search.trim() ||
`${t.title} ${t.notes ?? ''} ${t.tags.join(' ')}`
.toLowerCase()
.includes(search.trim().toLowerCase()));
const visible = $derived(data.tasks.filter(matches));
const visibleIds = $derived(new Set(visible.map((t) => t.id)));
const childrenOf = $derived.by(() => {
const map: Record<number, Task[]> = {};
for (const t of visible)
if (t.parentId !== null && visibleIds.has(t.parentId)) (map[t.parentId] ??= []).push(t);
return map;
});
const rank = (t: Task) => (t.status === 'in_progress' ? 0 : t.status === 'todo' ? 1 : 2);
const byUrgency = (a: Task, b: Task) =>
rank(a) - rank(b) ||
Number(b.flagged) - Number(a.flagged) ||
(a.dueDate ?? '9999').localeCompare(b.dueDate ?? '9999') ||
b.priority - a.priority ||
a.sortOrder - b.sortOrder;
const groups = $derived.by(() => {
const roots = visible.filter((t) => t.parentId === null || !visibleIds.has(t.parentId));
const list = data.areas.map((a) => ({
key: String(a.id),
name: a.name,
description: a.description,
tasks: roots.filter((t) => t.areaId === a.id).sort(byUrgency),
total: data.tasks.filter((t) => t.areaId === a.id).length,
done: data.tasks.filter((t) => t.areaId === a.id && t.status === 'done').length
}));
const loose = roots.filter((t) => t.areaId === null).sort(byUrgency);
if (loose.length)
list.push({
key: 'none',
name: 'No area',
description: null,
tasks: loose,
total: loose.length,
done: 0
});
return list.filter((g) => g.tasks.length);
});
const counts = $derived.by(() => {
const c = { todo: 0, in_progress: 0, done: 0, dropped: 0, overdue: 0, week: 0, flagged: 0 };
for (const t of data.tasks) {
c[t.status]++;
if (!isOpen(t)) continue;
if (t.flagged) c.flagged++;
const d = dueState(t);
if (d === 'overdue') c.overdue++;
else if (d === 'today' || d === 'soon') c.week++;
}
return c;
});
const upcoming = $derived(
data.tasks
.filter((t) => isOpen(t) && t.dueDate && daysUntil(t.dueDate) <= 14)
.sort((a, b) =>
`${a.dueDate}${a.dueTime ?? ''}`.localeCompare(`${b.dueDate}${b.dueTime ?? ''}`)
)
);
// Dates
function daysUntil(day: string) {
return Math.round(
(Date.parse(`${day}T12:00:00Z`) - Date.parse(`${data.today}T12:00:00Z`)) / 86_400_000
);
}
function dueState(t: Task): 'overdue' | 'today' | 'soon' | 'later' | null {
if (!t.dueDate) return null;
const n = daysUntil(t.dueDate);
return n < 0 ? 'overdue' : n === 0 ? 'today' : n <= 7 ? 'soon' : 'later';
}
function dueLabel(t: Task) {
if (!t.dueDate) return '';
const n = daysUntil(t.dueDate);
const time = t.dueTime ? ` ${t.dueTime.slice(0, 5)}` : '';
if (n === 0) return `today${time}`;
if (n === 1) return `tomorrow${time}`;
if (n === -1) return `yesterday${time}`;
const date = new Date(`${t.dueDate}T12:00:00Z`).toLocaleDateString('en-GB', {
weekday: n > 0 && n < 7 ? 'short' : undefined,
day: 'numeric',
month: 'short',
year: t.dueDate.slice(0, 4) === data.today.slice(0, 4) ? undefined : 'numeric',
timeZone: 'UTC'
});
return `${date}${time}`;
}
const localMoment = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Amsterdam',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const reminderLabel = (d: Date) =>
d.toLocaleString('en-GB', {
timeZone: 'Europe/Amsterdam',
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
const areaName = (id: number | null) => data.areas.find((a) => a.id === id)?.name ?? '';
const goalTitle = (id: number | null) => data.goals.find((g) => g.id === id)?.title ?? '';
// Goals
const goalStats = (g: Goal) => {
const linked = data.tasks.filter((t) => t.goalId === g.id && t.status !== 'dropped');
return { total: linked.length, done: linked.filter((t) => t.status === 'done').length };
};
const metricPct = (g: Goal) =>
g.metricTarget && g.metricCurrent !== null
? Math.max(0, Math.min(100, (g.metricCurrent / g.metricTarget) * 100))
: null;
const money = (n: number, unit: string | null) =>
unit === 'EUR'
? new Intl.NumberFormat('nl-NL', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0
}).format(n)
: `${n.toLocaleString('en-GB')}${unit ? ` ${unit}` : ''}`;
function targetLabel(day: string) {
const n = daysUntil(day);
const date = new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', {
month: 'short',
year: 'numeric',
timeZone: 'UTC'
});
if (n < 0) return `${date} (passed)`;
if (n < 60) return `${date} · ${n} days`;
if (n < 730) return `${date} · ${Math.round(n / 30.4)} months`;
return `${date} · ${(n / 365.25).toFixed(1)} years`;
}
// Editors
let taskDialog = $state(false);
let goalDialog = $state(false);
let editing = $state<Task | null>(null);
let editingGoal = $state<Goal | null>(null);
let confirmDelete = $state(false);
let error = $state<string | null>(null);
let quickError = $state<string | null>(null);
let quickArea = $state('');
function openTask(t: Task | null = null) {
editing = t;
confirmDelete = false;
error = null;
taskDialog = true;
}
function openGoal(g: Goal | null = null) {
editingGoal = g;
confirmDelete = false;
error = null;
goalDialog = true;
}
const submitDialog: SubmitFunction =
() =>
async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') taskDialog = goalDialog = false;
else error = String((result.type === 'failure' && result.data?.message) || 'Could not save');
};
const submitQuick: SubmitFunction =
() =>
async ({ result, update }) => {
await update();
quickError =
result.type === 'failure' ? String(result.data?.message ?? 'Could not add') : null;
};
const submitInline: SubmitFunction =
() =>
async ({ update }) => {
await update({ reset: false });
};
</script>
{#snippet statusButton(t: Task)}
{@const Icon = STATUS_ICON[t.status]}
<form method="POST" action="?/status" use:enhance={submitInline} class="contents">
<input type="hidden" name="id" value={t.id} />
<input type="hidden" name="status" value={NEXT[t.status]} />
<button
class="status status-{t.status}"
type="submit"
title={`${STATUS_LABEL[t.status]}: mark ${STATUS_LABEL[NEXT[t.status]].toLowerCase()}`}
aria-label={`${t.title}: ${STATUS_LABEL[t.status]}, mark ${STATUS_LABEL[NEXT[t.status]].toLowerCase()}`}
><Icon size={18} /></button
>
</form>
{/snippet}
{#snippet taskRow(t: Task, depth: number)}
{@const due = dueState(t)}
<li class="task" class:closed={!isOpen(t)} style:padding-left={`${depth * 1.5}rem`}>
{@render statusButton(t)}
<div class="min-w-0 flex-1">
<p class="flex flex-wrap items-center gap-x-2 gap-y-1">
<button type="button" class="title" onclick={() => openTask(t)}>{t.title}</button>
{#if t.flagged}<Flag size={13} class="text-red-500" aria-label="Flagged" />{/if}
{#if t.priority >= 2}<span class="chip prio-{t.priority}">{PRIORITY[t.priority]}</span>{/if}
{#if t.dueDate}<span class="chip due-{isOpen(t) ? due : 'later'}">{dueLabel(t)}</span>{/if}
{#if t.reminderAt && isOpen(t)}<span class="chip" title="Reminder"
><Bell size={11} /> {reminderLabel(t.reminderAt)}</span
>{/if}
{#if t.recurrence}<Repeat size={12} aria-label="Repeats" />{/if}
{#each t.tags as tag (tag)}<span class="tag">#{tag}</span>{/each}
{#if t.goalId}<span class="tag"><Target size={11} /> {goalTitle(t.goalId)}</span>{/if}
</p>
{#if t.notes}<p class="notes">{t.notes}</p>{/if}
</div>
<form method="POST" action="?/status" use:enhance={submitInline} class="hidden sm:block">
<input type="hidden" name="id" value={t.id} />
<select
name="status"
class="status-select"
aria-label={`Status of ${t.title}`}
value={t.status}
onchange={(e) => e.currentTarget.form?.requestSubmit()}
>
{#each Object.entries(STATUS_LABEL) as [value, label] (value)}<option {value}
>{label}</option
>{/each}
</select>
</form>
<button class="icon" type="button" aria-label={`Edit ${t.title}`} onclick={() => openTask(t)}
><Pencil size={14} /></button
>
</li>
{#each childrenOf[t.id] ?? [] as child (child.id)}
{@render taskRow(child, depth + 1)}
{/each}
{/snippet}
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">tasks</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>
<dl class="stats">
<div>
<dt>to do</dt>
<dd>{counts.todo}</dd>
</div>
<div>
<dt>in progress</dt>
<dd>{counts.in_progress}</dd>
</div>
<div>
<dt>done</dt>
<dd>{counts.done}</dd>
</div>
<div class:alert={counts.overdue > 0}>
<dt>overdue</dt>
<dd>{counts.overdue}</dd>
</div>
<div>
<dt>this week</dt>
<dd>{counts.week}</dd>
</div>
<div>
<dt>flagged</dt>
<dd>{counts.flagged}</dd>
</div>
</dl>
</div>
<form
method="POST"
action="?/quick"
use:enhance={submitQuick}
class="panel mb-4 flex flex-wrap items-center gap-2"
>
<Input
name="title"
required
maxlength={500}
placeholder="Add a task… (end with ! to flag, !!! for high priority)"
class="min-w-[14rem] flex-1"
aria-label="New task"
/>
<NativeSelect name="areaId" bind:value={quickArea} aria-label="Area">
<option value="">No area</option>
{#each data.areas as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
</NativeSelect>
<Input name="due" type="date" class="w-auto" aria-label="Due date" />
<Button type="submit"><Plus size={16} /> Add</Button>
{#if quickError}<p class="w-full text-sm text-red-500" role="alert">{quickError}</p>{/if}
</form>
<div class="mb-6 flex flex-wrap items-center gap-2">
<div class="segmented" role="group" aria-label="Status filter">
{#each STATUS_FILTERS as [value, label] (value)}
<button
type="button"
aria-pressed={statusFilter === value}
onclick={() => (statusFilter = value)}>{label}</button
>
{/each}
</div>
<NativeSelect bind:value={areaFilter} aria-label="Area filter">
<option value="all">All areas</option>
{#each data.areas as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
<option value="none">No area</option>
</NativeSelect>
<NativeSelect bind:value={priorityFilter} aria-label="Priority filter">
<option value="any">Any priority</option>
<option value="flagged">Flagged (!)</option>
<option value="high">Flagged or medium/high</option>
</NativeSelect>
<Input
type="search"
bind:value={search}
placeholder="Search…"
class="w-auto min-w-[10rem]"
aria-label="Search tasks"
/>
<Button type="button" variant="outline" onclick={() => openTask()}
><Plus size={16} /> Detailed task</Button
>
</div>
{#if upcoming.length && statusFilter === 'open' && areaFilter === 'all' && !search}
<section class="panel mb-6">
<h2 class="mb-2 text-lg font-semibold tracking-tight">Next two weeks</h2>
<ul class="divide-y divide-[var(--site-border)]">
{#each upcoming as t (t.id)}
{@const due = dueState(t)}
<li class="flex items-center gap-3 py-1.5 text-sm">
{@render statusButton(t)}
<span class="chip due-{due} w-28 shrink-0 justify-center">{dueLabel(t)}</span>
<button type="button" class="title truncate" onclick={() => openTask(t)}
>{t.title}</button
>
<small class="ml-auto hidden shrink-0 sm:inline">{areaName(t.areaId)}</small>
</li>
{/each}
</ul>
</section>
{/if}
<div class="grid gap-4 lg:grid-cols-2">
{#each groups as g (g.key)}
<section class="panel">
<header class="mb-2 flex items-baseline justify-between gap-2">
<div>
<h2 class="text-lg font-semibold tracking-tight">{g.name}</h2>
{#if g.description}<small>{g.description}</small>{/if}
</div>
<small class="shrink-0 tabular-nums">{g.done}/{g.total} done</small>
</header>
{#if g.total}<div class="bar mb-2" aria-hidden="true">
<span style:width={`${(g.done / g.total) * 100}%`}></span>
</div>{/if}
<ul class="divide-y divide-[var(--site-border)]">
{#each expanded[g.key] ? g.tasks : g.tasks.slice(0, PAGE) as t (t.id)}{@render taskRow(
t,
0
)}{/each}
</ul>
{#if g.tasks.length > PAGE && !expanded[g.key]}
<Button
type="button"
variant="ghost"
size="sm"
class="mt-2"
onclick={() => (expanded[g.key] = true)}>Show all {g.tasks.length}</Button
>
{/if}
</section>
{:else}
<p class="text-[var(--site-muted)]">No tasks match these filters.</p>
{/each}
</div>
<section class="mt-12">
<header class="mb-4 flex items-center justify-between gap-2">
<h2 class="text-2xl font-semibold tracking-tight">goals</h2>
<Button type="button" variant="outline" onclick={() => openGoal()}
><Plus size={16} /> Goal</Button
>
</header>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.goals as goal (goal.id)}
{@const stats = goalStats(goal)}
{@const pct = metricPct(goal)}
<button
type="button"
class="panel goal text-left"
class:muted={goal.status !== 'active'}
onclick={() => openGoal(goal)}
>
<p class="mb-1 flex items-start justify-between gap-2">
<strong class="leading-snug">{goal.title}</strong>
{#if goal.status !== 'active'}<span class="chip">{goal.status}</span
>{:else if goal.flagged}<Flag size={13} class="shrink-0 text-red-500" />{/if}
</p>
{#if goal.notes}<p class="notes mb-2">{goal.notes}</p>{/if}
{#if goal.targetDate}<small class="block">🎯 {targetLabel(goal.targetDate)}</small>{/if}
{#if goal.metricTarget !== null}
<small class="block"
>{goal.metric ?? 'Target'}: {goal.metricCurrent !== null
? `${money(goal.metricCurrent, goal.metricUnit)} / `
: ''}{money(goal.metricTarget, goal.metricUnit)}</small
>
{#if pct !== null}<div class="bar mt-1" aria-hidden="true">
<span style:width={`${pct}%`}></span>
</div>{/if}
{/if}
{#if stats.total}
<small class="mt-1 block">{stats.done}/{stats.total} linked tasks done</small>
<div class="bar mt-1" aria-hidden="true">
<span style:width={`${(stats.done / stats.total) * 100}%`}></span>
</div>
{/if}
</button>
{:else}
<p class="text-[var(--site-muted)]">No goals yet.</p>
{/each}
</div>
</section>
</div>
<Dialog.Root bind:open={taskDialog}>
<Dialog.Content class="block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
<Dialog.Header class="mb-4 pr-8">
<Dialog.Title
>{confirmDelete ? 'Delete task' : editing ? 'Edit task' : 'New task'}</Dialog.Title
>
<Dialog.Description class="sr-only">Manage a private task.</Dialog.Description>
</Dialog.Header>
{#if confirmDelete && editing}
<form method="POST" action="?/delete" use:enhance={submitDialog}>
<input type="hidden" name="id" value={editing.id} />
<p class="mb-4 text-sm">
Delete “{editing.title}”{childrenOf[editing.id]?.length ? ' and its subtasks' : ''}? This
cannot be undone. (Marking it dropped keeps the history.)
</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={() => (confirmDelete = false)}
>Back</Button
>
<Button type="submit" variant="destructive">Delete</Button>
</div>
</form>
{:else}
{#key editing?.id ?? 'new'}
<form method="POST" action="?/save" use:enhance={submitDialog} class="grid gap-3">
{#if editing}<input type="hidden" name="id" value={editing.id} />{/if}
<label class="grid gap-1 text-sm"
><span>Title *</span>
<Input name="title" required maxlength={500} value={editing?.title ?? ''} /></label
>
<label class="grid gap-1 text-sm"
><span>Notes</span>
<Textarea name="notes" rows={3} value={editing?.notes ?? ''} /></label
>
<div class="grid grid-cols-2 gap-3">
<label class="grid gap-1 text-sm"
><span>Status</span>
<NativeSelect name="status" value={editing?.status ?? 'todo'}>
{#each Object.entries(STATUS_LABEL) as [value, label] (value)}<option {value}
>{label}</option
>{/each}
</NativeSelect></label
>
<label class="grid gap-1 text-sm"
><span>Priority</span>
<NativeSelect name="priority" value={String(editing?.priority ?? 0)}>
{#each PRIORITY as label, i (label)}<option value={String(i)}>{label}</option
>{/each}
</NativeSelect></label
>
<label class="grid gap-1 text-sm"
><span>Due date</span>
<Input name="due" type="date" value={editing?.dueDate ?? ''} /></label
>
<label class="grid gap-1 text-sm"
><span>Due time</span>
<Input
name="dueTime"
type="time"
value={editing?.dueTime?.slice(0, 5) ?? ''}
/></label
>
<label class="grid gap-1 text-sm"
><span>Area</span>
<NativeSelect
name="areaId"
value={editing ? String(editing.areaId ?? '') : quickArea}
>
<option value="">No area</option>
{#each data.areas as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
</NativeSelect></label
>
<label class="grid gap-1 text-sm"
><span>Goal</span>
<NativeSelect name="goalId" value={String(editing?.goalId ?? '')}>
<option value="">None</option>
{#each data.goals as g (g.id)}<option value={String(g.id)}>{g.title}</option>{/each}
</NativeSelect></label
>
<label class="grid gap-1 text-sm"
><span>Reminder</span>
<Input
name="reminderAt"
type="datetime-local"
value={editing?.reminderAt
? localMoment.format(editing.reminderAt).replace(' ', 'T')
: ''}
/></label
>
<label class="grid gap-1 text-sm"
><span>Subtask of</span>
<NativeSelect name="parentId" value={String(editing?.parentId ?? '')}>
<option value="">—</option>
{#each data.tasks.filter((t) => t.id !== editing?.id && t.parentId === null && isOpen(t)) as t (t.id)}<option
value={String(t.id)}>{t.title}</option
>{/each}
</NativeSelect></label
>
</div>
<label class="grid gap-1 text-sm"
><span>Tags <small>(comma or space separated)</small></span>
<Input name="tags" value={editing?.tags.join(', ') ?? ''} /></label
>
<label class="grid gap-1 text-sm"
><span>Repeat <small>(RRULE, e.g. FREQ=WEEKLY;BYDAY=MO; not automatic yet)</small></span
>
<Input name="recurrence" value={editing?.recurrence ?? ''} /></label
>
<label class="flex items-center gap-2 text-sm"
><input type="checkbox" name="flagged" checked={editing?.flagged ?? false} /> Flagged (!)</label
>
{#if editing}<small
>Created {editing.createdAt.toLocaleDateString('en-GB')}{editing.completedAt
? ` · closed ${editing.completedAt.toLocaleDateString('en-GB')}`
: ''}{editing.source ? ` · ${editing.source}` : ''}</small
>{/if}
{#if error}<p class="text-sm text-red-500" role="alert">{error}</p>{/if}
<div class="mt-2 flex justify-between gap-2">
{#if editing}<Button
type="button"
variant="ghost"
class="text-red-500"
onclick={() => (confirmDelete = true)}>Delete</Button
>{:else}<span></span>{/if}
<div class="flex gap-2">
<Button type="button" variant="outline" onclick={() => (taskDialog = false)}
>Cancel</Button
>
<Button type="submit">Save</Button>
</div>
</div>
</form>
{/key}
{/if}
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={goalDialog}>
<Dialog.Content class="block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
<Dialog.Header class="mb-4 pr-8">
<Dialog.Title
>{confirmDelete ? 'Delete goal' : editingGoal ? 'Edit goal' : 'New goal'}</Dialog.Title
>
<Dialog.Description class="sr-only">Manage a private goal.</Dialog.Description>
</Dialog.Header>
{#if confirmDelete && editingGoal}
<form method="POST" action="?/deleteGoal" use:enhance={submitDialog}>
<input type="hidden" name="id" value={editingGoal.id} />
<p class="mb-4 text-sm">
Delete “{editingGoal.title}”? Linked tasks stay, without the goal.
</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={() => (confirmDelete = false)}
>Back</Button
>
<Button type="submit" variant="destructive">Delete</Button>
</div>
</form>
{:else}
{#key editingGoal?.id ?? 'new'}
<form method="POST" action="?/saveGoal" use:enhance={submitDialog} class="grid gap-3">
{#if editingGoal}<input type="hidden" name="id" value={editingGoal.id} />{/if}
<label class="grid gap-1 text-sm"
><span>Title *</span>
<Input name="title" required maxlength={300} value={editingGoal?.title ?? ''} /></label
>
<label class="grid gap-1 text-sm"
><span>Notes</span>
<Textarea name="notes" rows={2} value={editingGoal?.notes ?? ''} /></label
>
<div class="grid grid-cols-2 gap-3">
<label class="grid gap-1 text-sm"
><span>Status</span>
<NativeSelect name="status" value={editingGoal?.status ?? 'active'}>
<option value="active">Active</option>
<option value="achieved">Achieved</option>
<option value="dropped">Dropped</option>
</NativeSelect></label
>
<label class="grid gap-1 text-sm"
><span>Target date</span>
<Input name="targetDate" type="date" value={editingGoal?.targetDate ?? ''} /></label
>
<label class="grid gap-1 text-sm"
><span>Area</span>
<NativeSelect name="areaId" value={String(editingGoal?.areaId ?? '')}>
<option value="">No area</option>
{#each data.areas as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
</NativeSelect></label
>
<label class="grid gap-1 text-sm"
><span>Metric</span>
<Input
name="metric"
value={editingGoal?.metric ?? ''}
placeholder="e.g. Net income per month"
/></label
>
<label class="grid gap-1 text-sm"
><span>Current</span>
<Input
name="metricCurrent"
inputmode="decimal"
value={editingGoal?.metricCurrent ?? ''}
/></label
>
<label class="grid gap-1 text-sm"
><span>Target</span>
<Input
name="metricTarget"
inputmode="decimal"
value={editingGoal?.metricTarget ?? ''}
/></label
>
<label class="grid gap-1 text-sm"
><span>Unit</span>
<Input
name="metricUnit"
value={editingGoal?.metricUnit ?? ''}
placeholder="EUR, kg…"
/></label
>
<label class="flex items-center gap-2 self-end pb-2 text-sm"
><input type="checkbox" name="flagged" checked={editingGoal?.flagged ?? false} /> Flagged</label
>
</div>
{#if error}<p class="text-sm text-red-500" role="alert">{error}</p>{/if}
<div class="mt-2 flex justify-between gap-2">
{#if editingGoal}<Button
type="button"
variant="ghost"
class="text-red-500"
onclick={() => (confirmDelete = true)}>Delete</Button
>{:else}<span></span>{/if}
<div class="flex gap-2">
<Button type="button" variant="outline" onclick={() => (goalDialog = false)}
>Cancel</Button
>
<Button type="submit">Save</Button>
</div>
</div>
</form>
{/key}
{/if}
</Dialog.Content>
</Dialog.Root>
<style>
.panel {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 1rem 1.25rem;
}
small,
.notes {
color: var(--site-muted);
}
.notes {
font-size: 0.8rem;
line-height: 1.35;
margin-top: 0.15rem;
}
.stats {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
}
.stats dt {
font-size: 0.75rem;
color: var(--site-muted);
}
.stats dd {
font-size: 1.5rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.stats .alert dd {
color: rgb(239 68 68);
}
.task {
display: flex;
align-items: flex-start;
gap: 0.6rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.task.closed .title {
text-decoration: line-through;
color: var(--site-muted);
}
.title {
text-align: left;
font-size: 0.925rem;
}
.title:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.status {
display: inline-flex;
flex-shrink: 0;
border-radius: 999px;
color: var(--site-muted);
margin-top: 0.05rem;
}
.status:hover {
color: var(--site-text);
}
.status-in_progress {
color: rgb(245 158 11);
}
.status-done {
color: rgb(34 197 94);
}
.status-select {
font-size: 0.75rem;
background: transparent;
color: var(--site-muted);
border: 1px solid var(--site-border);
border-radius: 0.375rem;
padding: 0.1rem 0.25rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 0.7rem;
line-height: 1;
padding: 0.2rem 0.45rem;
border-radius: 999px;
border: 1px solid var(--site-border);
color: var(--site-muted);
white-space: nowrap;
}
.due-overdue {
background: rgb(239 68 68 / 0.15);
border-color: rgb(239 68 68 / 0.5);
color: rgb(239 68 68);
}
.due-today {
background: rgb(245 158 11 / 0.18);
border-color: rgb(245 158 11 / 0.55);
color: rgb(217 119 6);
}
.due-soon {
border-color: rgb(59 130 246 / 0.5);
color: rgb(59 130 246);
}
.prio-3 {
color: rgb(239 68 68);
}
.prio-2 {
color: rgb(245 158 11);
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 0.7rem;
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);
}
.segmented {
display: inline-flex;
flex-wrap: wrap;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
overflow: hidden;
}
.segmented button {
font-size: 0.8rem;
padding: 0.35rem 0.7rem;
color: var(--site-muted);
}
.segmented button[aria-pressed='true'] {
background: color-mix(in srgb, var(--site-text) 12%, transparent);
color: var(--site-text);
font-weight: 500;
}
.bar {
height: 4px;
border-radius: 999px;
background: color-mix(in srgb, var(--site-muted) 20%, transparent);
overflow: hidden;
}
.bar span {
display: block;
height: 100%;
background: rgb(34 197 94);
border-radius: inherit;
}
.goal {
display: block;
transition: border-color 0.15s;
}
.goal:hover {
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
}
.goal.muted {
opacity: 0.6;
}
</style>

View File

@@ -365,7 +365,7 @@ test('shared dialogs have focus, Escape dismissal and mobile layouts without ove
await expect(dialog).toHaveCount(0);
await expect(page.getByRole('button', { name: label, exact: true })).toBeFocused();
}
await page.goto('/projects/money-tracker');
await page.goto('/trackers/money');
await page.getByRole('heading', { name: 'Accounts', exact: true }).waitFor();
await page.locator('#accounts').getByRole('button', { name: 'Add', exact: true }).click();
const moneyDialog = page.getByRole('dialog');