Compare commits
17 Commits
books-ui-r
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b77c3f8cf5 | |||
| 1754ea9316 | |||
| 6a127de950 | |||
| 9d08e6af23 | |||
| 941a9f16e1 | |||
| c8205e474e | |||
| 81ee02f11a | |||
| eb80a830f5 | |||
| 988c6c31a9 | |||
| 2af279e8a9 | |||
| faeb76a7e7 | |||
| a022c45499 | |||
| 6f82d1350e | |||
| 7a039b23f4 | |||
| 8445eef3cf | |||
| 1d4cfd58c5 | |||
| 763d7881fb |
@@ -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.
|
||||
|
||||
643
data/tasks/daisy-import.json
Normal file
643
data/tasks/daisy-import.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
data/tasks/ticktick-merges.json
Normal file
7
data/tasks/ticktick-merges.json
Normal 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" }
|
||||
]
|
||||
@@ -21,7 +21,8 @@ export default defineConfig({
|
||||
'site',
|
||||
'meta',
|
||||
'health',
|
||||
'books'
|
||||
'books',
|
||||
'tasks'
|
||||
],
|
||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||
verbose: true,
|
||||
|
||||
@@ -170,8 +170,12 @@ 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;
|
||||
-- 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 $$;
|
||||
|
||||
--> statement-breakpoint
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "money"."accounts" ADD COLUMN "include_in_total" boolean DEFAULT true NOT NULL;
|
||||
13735
drizzle/20261024021157_money_include_in_total/snapshot.json
Normal file
13735
drizzle/20261024021157_money_include_in_total/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
142
drizzle/20261024022836_tasks/migration.sql
Normal file
142
drizzle/20261024022836_tasks/migration.sql
Normal 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 $$;
|
||||
14935
drizzle/20261024022836_tasks/snapshot.json
Normal file
14935
drizzle/20261024022836_tasks/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
29
drizzle/20261024024249_meta_edit_log/migration.sql
Normal file
29
drizzle/20261024024249_meta_edit_log/migration.sql
Normal 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 $$;
|
||||
15132
drizzle/20261024024249_meta_edit_log/snapshot.json
Normal file
15132
drizzle/20261024024249_meta_edit_log/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2
drizzle/20261024030132_tasks_ticktick/migration.sql
Normal file
2
drizzle/20261024030132_tasks_ticktick/migration.sql
Normal 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;
|
||||
15158
drizzle/20261024030132_tasks_ticktick/snapshot.json
Normal file
15158
drizzle/20261024030132_tasks_ticktick/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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
19
scripts/db-meta-sync.ts
Normal 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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
46
scripts/tasks-import.ts
Normal 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();
|
||||
}
|
||||
79
scripts/ticktick-export.ts
Normal file
79
scripts/ticktick-export.ts
Normal 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)` : '')
|
||||
);
|
||||
65
scripts/ticktick-import.ts
Normal file
65
scripts/ticktick-import.ts
Normal 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();
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
if (!isFixed(v)) {
|
||||
r.setAttribute('flow', l.mode === 'scroll' ? 'scrolled' : 'paginated');
|
||||
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));
|
||||
// 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');
|
||||
|
||||
@@ -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;
|
||||
// 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 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;
|
||||
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();
|
||||
});
|
||||
}
|
||||
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);
|
||||
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();
|
||||
const v = scroller.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));
|
||||
resize.observe(scroller);
|
||||
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);
|
||||
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,14 +513,19 @@
|
||||
bind:this={scroller}
|
||||
{onscroll}
|
||||
>
|
||||
<div class="pages">
|
||||
{#each pages as p (p.num)}
|
||||
<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)}
|
||||
>
|
||||
@@ -417,6 +546,8 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -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;
|
||||
|
||||
111
src/lib/books/reader/layout.test.ts
Normal file
111
src/lib/books/reader/layout.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
124
src/lib/books/reader/layout.ts
Normal file
124
src/lib/books/reader/layout.ts
Normal 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
|
||||
}
|
||||
}
|
||||
39
src/lib/books/reader/pdf.test.ts
Normal file
39
src/lib/books/reader/pdf.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
18
src/lib/books/reader/pdf.ts
Normal file
18
src/lib/books/reader/pdf.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
155
src/lib/components/data/FieldInput.svelte
Normal file
155
src/lib/components/data/FieldInput.svelte
Normal 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>
|
||||
72
src/lib/components/data/data-format.ts
Normal file
72
src/lib/components/data/data-format.ts
Normal 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');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
26
src/lib/money-tracker/wishlist.test.ts
Normal file
26
src/lib/money-tracker/wishlist.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
25
src/lib/money-tracker/wishlist.ts
Normal file
25
src/lib/money-tracker/wishlist.ts
Normal 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
21
src/lib/projects.test.ts
Normal 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]+$/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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[]>`
|
||||
// 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)}`;
|
||||
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}`);
|
||||
// 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 status = 'authorized'`;
|
||||
where id = ${session.id}`;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
758
src/lib/server/data-editor.ts
Normal file
758
src/lib/server/data-editor.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
146
src/lib/server/data-policy.test.ts
Normal file
146
src/lib/server/data-policy.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
244
src/lib/server/data-policy.ts
Normal file
244
src/lib/server/data-policy.ts
Normal 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] === ''));
|
||||
}
|
||||
50
src/lib/server/data-route.ts
Normal file
50
src/lib/server/data-route.ts
Normal 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'
|
||||
};
|
||||
@@ -14,3 +14,4 @@ export * from './cs-prices';
|
||||
export * from './house-tracker';
|
||||
export * from './health';
|
||||
export * from './books';
|
||||
export * from './tasks';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
|
||||
145
src/lib/server/db/schema/tasks.ts
Normal file
145
src/lib/server/db/schema/tasks.ts
Normal 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;
|
||||
@@ -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[];
|
||||
|
||||
@@ -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))
|
||||
};
|
||||
|
||||
@@ -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']]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}/`);
|
||||
|
||||
30
src/lib/server/reconcile.test.ts
Normal file
30
src/lib/server/reconcile.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
49
src/lib/server/reconcile.ts
Normal file
49
src/lib/server/reconcile.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
62
src/lib/server/tasks.test.ts
Normal file
62
src/lib/server/tasks.test.ts
Normal 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
338
src/lib/server/tasks.ts
Normal 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);
|
||||
}
|
||||
140
src/lib/server/ticktick.test.ts
Normal file
140
src/lib/server/ticktick.test.ts
Normal 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
723
src/lib/server/ticktick.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.' });
|
||||
|
||||
@@ -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)}
|
||||
|
||||
10
src/routes/lists/db/+layout.server.ts
Normal file
10
src/routes/lists/db/+layout.server.ts
Normal 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 {};
|
||||
};
|
||||
9
src/routes/lists/db/+page.server.ts
Normal file
9
src/routes/lists/db/+page.server.ts
Normal 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');
|
||||
};
|
||||
18
src/routes/lists/db/[schema]/[table]/+page.server.ts
Normal file
18
src/routes/lists/db/[schema]/[table]/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
806
src/routes/lists/db/[schema]/[table]/+page.svelte
Normal file
806
src/routes/lists/db/[schema]/[table]/+page.svelte
Normal 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>
|
||||
25
src/routes/lists/db/[schema]/[table]/columns/+server.ts
Normal file
25
src/routes/lists/db/[schema]/[table]/columns/+server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
26
src/routes/lists/db/[schema]/[table]/export/+server.ts
Normal file
26
src/routes/lists/db/[schema]/[table]/export/+server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
26
src/routes/lists/db/[schema]/[table]/import/+server.ts
Normal file
26
src/routes/lists/db/[schema]/[table]/import/+server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
13
src/routes/lists/db/[schema]/[table]/lookup/+server.ts
Normal file
13
src/routes/lists/db/[schema]/[table]/lookup/+server.ts
Normal 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 });
|
||||
};
|
||||
62
src/routes/lists/db/[schema]/[table]/rows/+server.ts
Normal file
62
src/routes/lists/db/[schema]/[table]/rows/+server.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
10
src/routes/projects/money-tracker/[...rest]/+server.ts
Normal file
10
src/routes/projects/money-tracker/[...rest]/+server.ts
Normal 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;
|
||||
30
src/routes/projects/money-tracker/[...rest]/redirect.test.ts
Normal file
30
src/routes/projects/money-tracker/[...rest]/redirect.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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,21 +433,21 @@
|
||||
onloading={(f) => (loading = f)}
|
||||
onerror={(m) => (failure = m)}
|
||||
/>
|
||||
{#if ready && settings.flow === 'paginated'}
|
||||
{/if}
|
||||
{#if ready && (layout.mode === 'paged' || fixedLayout)}
|
||||
<button
|
||||
type="button"
|
||||
class="turn left"
|
||||
aria-label="Previous page"
|
||||
aria-label={layout.spread === 2 ? 'Previous pages' : 'Previous page'}
|
||||
onclick={() => engine?.prev()}><ChevronLeft size={22} /></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="turn right"
|
||||
aria-label="Next page"
|
||||
aria-label={layout.spread === 2 ? 'Next pages' : 'Next page'}
|
||||
onclick={() => engine?.next()}><ChevronRight size={22} /></button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if failure}
|
||||
<div class="overlay">
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
<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} · 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"
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
@@ -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>
|
||||
11
src/routes/trackers/tasks/+layout.server.ts
Normal file
11
src/routes/trackers/tasks/+layout.server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
// Owner only: everyone else (logged out, or any other account) gets a plain 404.
|
||||
// Form actions don't run this load, so +page.server.ts checks again in every action,
|
||||
// and hooks.server.ts blocks the whole /trackers/tasks prefix as a backstop.
|
||||
export const load: LayoutServerLoad = (event) => {
|
||||
requireOwner(event);
|
||||
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||
return {};
|
||||
};
|
||||
261
src/routes/trackers/tasks/+page.server.ts
Normal file
261
src/routes/trackers/tasks/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
920
src/routes/trackers/tasks/+page.svelte
Normal file
920
src/routes/trackers/tasks/+page.svelte
Normal 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>
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user