30 Commits

Author SHA1 Message Date
2cada618bf books: regenerate migration as 20261024013208_books after money_bank_balance (old 010449/010518 would sort before the applied 011919 and be skipped)
Some checks failed
Deploy production / deploy (push) Failing after 27s
2026-10-24 01:35:04 +00:00
7914b0140b books-ingest: --no-move rehearsal mode, stable cover filenames, finished status, module-level log 2026-10-24 01:31:57 +00:00
178fa8ddce books-ingest: safer title/ISBN matching, junk-author filter, GB quota breaker 2026-10-24 01:31:57 +00:00
9c8f20a618 books: immersive reader route, PDF resume fix, scalable placeholder covers, read-only books mount in prod deploy, README 2026-10-24 01:31:57 +00:00
9c46e281c2 books-ui: library, detail page, EPUB/PDF reader, reading status/rating/sessions migration, progress/notes APIs, owner gating and tests 2026-10-24 01:31:57 +00:00
8b2dfed239 books: schema, migration and ingest script (rebased onto main; migration regenerated after money_bank_sync) 2026-10-24 01:31:57 +00:00
2da375451a Money tracker: bank balance is the source of truth for synced accounts
All checks were successful
Deploy production / deploy (push) Successful in 2m43s
Sync stores the bank-reported balance (ITAV preferred) on the account and keeps
one Bank balance adjustment row so the tracker total equals the bank balance.
Accounts panel shows the bank balance and flags any drift.
2026-10-24 01:20:20 +00:00
e4de2dcec3 Add privacy and terms pages
All checks were successful
Deploy production / deploy (push) Successful in 2m28s
2026-10-24 00:50:46 +00:00
00c1e8b1e7 money: quick-add bar, server-filtered transactions page, CSV import dedupe + Revolut statements 2026-10-24 00:50:46 +00:00
9a1b21ec39 money: Enable Banking client, bank-link consent CLI, bank-sync with reconcile, systemd timer (not installed) 2026-10-24 00:50:45 +00:00
b1ce715ec7 money: bank-sync schema (account scope/iban/external uid, transaction external id/source, bank_sessions) 2026-10-24 00:50:45 +00:00
064d41595f preflight: verify health grants and energy data
All checks were successful
Deploy production / deploy (push) Successful in 2m20s
2026-10-24 00:27:29 +00:00
fa2456b99e Merge branch 'energy-tracker' into energy-merge 2026-10-24 00:25:34 +00:00
8aa689af64 health: drop hair_progress; owner = Stan's account email only
All checks were successful
Deploy production / deploy (push) Successful in 2m22s
Regenerated the (not yet deployed) health migration without hair_progress.
2026-10-24 00:22:24 +00:00
8275f1fb22 Add private health tracker (owner-only)
- health schema: vaccinations, body_measurements, workouts, nutrition_daily,
  sleep (generated hours), medications, hair_progress, appointments
- migration with updated_at triggers; grants to personal_app only
- /trackers/health overview with add/edit/delete, owner-gated (404 otherwise),
  noindex + private no-store, hidden from listings for non-owners
- owner-only JSON/CSV import endpoint and scripts/health-import.ts
2026-10-24 00:14:07 +00:00
0cf5d44062 Add energy tracker (/projects/energy)
Public, read-only page over the ned-collector energy schema: latest-hour
mix and per-source utilization, generation vs consumption with CO2
intensity (day/week/month/12 months/all years), NED forecast, gas storage
by year and per site, and gas-fired power share vs storage fill.
Incomplete hours (sources not summing to the NED total) are left out and
partial days/months/years are marked.
2026-10-23 23:11:27 +00:00
88837af624 Rename repo/image path to stan/website; retag legacy images on rollback
All checks were successful
Deploy production / deploy (push) Successful in 2m23s
2026-10-23 22:53:23 +00:00
45f2bbb198 db-refresh-dev: own dev DB as personal_dev, no prod grants; PG_CONTAINER mode
All checks were successful
Deploy production / deploy (push) Successful in 3m49s
2026-10-23 22:27:17 +00:00
36af5675e9 Merge db-schemas: Drizzle schemas, prod-only pipeline, trackers
Some checks failed
Deploy production / deploy (push) Failing after 14s
2026-10-23 22:09:54 +00:00
b8b366b542 Merge pull request 'staging' (#42) from staging into main
All checks were successful
build / build (push) Successful in 34s
build / deploy (push) Successful in 13s
Reviewed-on: stan/personal-website#42
2026-09-22 13:51:54 +00:00
6ed508702e Merge pull request 'staging' (#38) from staging into main
All checks were successful
build / build (push) Successful in 37s
build / deploy (push) Successful in 3s
Reviewed-on: stan/personal-website#38
2026-09-20 02:26:53 +00:00
46ab4a0adb Merge pull request 'staging' (#34) from staging into main
All checks were successful
build / build (push) Successful in 36s
build / deploy (push) Successful in 13s
Reviewed-on: stan/personal-website#34
2026-09-17 05:47:09 +00:00
08ab1f2716 Merge pull request 'staging' (#31) from staging into main
All checks were successful
build / build (push) Successful in 36s
build / deploy (push) Successful in 3s
Reviewed-on: stan/personal-website#31
2026-09-17 05:03:40 +00:00
e039f69afd Merge pull request 'staging' (#29) from staging into main
All checks were successful
build / build (push) Successful in 32s
build / deploy (push) Successful in 12s
Reviewed-on: stan/personal-website#29
2026-09-17 03:24:39 +00:00
1ff6bb859e Merge pull request 'staging' (#27) from staging into main
All checks were successful
build / build (push) Successful in 32s
build / deploy (push) Successful in 3s
Reviewed-on: stan/personal-website#27
2026-09-17 03:19:35 +00:00
997c54133f Merge pull request 'staging' (#21) from staging into main
All checks were successful
build / build (push) Successful in 33s
build / deploy (push) Successful in 13s
Reviewed-on: stan/personal-website#21
2026-09-01 22:00:25 +00:00
bdc8ac24ac Merge pull request 'staging' (#15) from staging into main
All checks were successful
build / build (push) Successful in 41s
build / deploy (push) Successful in 14s
Reviewed-on: stan/personal-website#15
2026-09-01 11:22:34 +00:00
c688b874c8 Merge pull request 'staging' (#12) from staging into main
All checks were successful
build / build (push) Successful in 31s
build / deploy (push) Successful in 13s
Reviewed-on: stan/personal-website#12
2026-08-31 16:43:42 +00:00
74f64c7457 Merge pull request 'base website' (#10) from staging into main
All checks were successful
build / build (push) Successful in 40s
build / deploy (push) Successful in 13s
Reviewed-on: stan/personal-website#10
2026-08-31 14:47:50 +00:00
245137c65d Merge pull request 'chore: add basic readme' (#1) from staging into main
All checks were successful
build / build (push) Successful in 41s
build / deploy (push) Successful in 17s
Reviewed-on: stan/personal-website#1
2026-08-26 04:36:48 +00:00
123 changed files with 68153 additions and 288 deletions

View File

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

View File

@@ -42,7 +42,7 @@ jobs:
context: . context: .
push: false push: false
load: true load: true
tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }} tags: git.stanrunge.dev/stan/website:${{ github.sha }}
build-args: | build-args: |
GIT_SHA=${{ github.sha }} GIT_SHA=${{ github.sha }}
@@ -50,7 +50,7 @@ jobs:
if: github.event_name == 'push' if: github.event_name == 'push'
run: | run: |
set -euo pipefail set -euo pipefail
image="git.stanrunge.dev/stan/personal-website:${{ github.sha }}" image="git.stanrunge.dev/stan/website:${{ github.sha }}"
docker save "$image" | ssh -o BatchMode=yes deploy@172.17.0.1 'sudo docker load' docker save "$image" | ssh -o BatchMode=yes deploy@172.17.0.1 'sudo docker load'
- name: Back up, migrate and deploy - name: Back up, migrate and deploy

View File

@@ -19,3 +19,9 @@ data/cod-challenges.json
data/game-images.json data/game-images.json
data/cod-achievement-art.json data/cod-achievement-art.json
src/lib/house-tracker/fixtures/ src/lib/house-tracker/fixtures/
# Vendored third-party code
static/vendor/
# Generated drizzle snapshots
drizzle/*/snapshot.json

View File

@@ -236,6 +236,30 @@ local database and migrations, and `db/reorg/README.md` for the one-time move of
old flat schema to this layout. Catalog seeds (`npm run db:seed`, `db:seed:cod`, …) old flat schema to this layout. Catalog seeds (`npm run db:seed`, `db:seed:cod`, …)
upsert reference data and run on every production deploy. upsert reference data and run on every production deploy.
## Book tracker
`/trackers/books` is the public catalogue (covers, status, progress, stats). `/trackers/books/<id>`
shows a book; the owner also sees files, notes, highlights and reading sessions there and can
edit status, rating and progress. `/trackers/books/<id>/read` is the owner-only reader:
EPUB, MOBI/AZW3 and FB2 open in [foliate-js](https://github.com/johnfactotum/foliate-js)
(vendored in `static/vendor/foliate-js`, no conversion), PDFs in pdf.js. The reader remembers the
position per file, autosaves progress and sessions, and stores highlights with notes.
Files are never public. They are streamed (with Range support) by
`/trackers/books/files/<id>` to the owner only; everyone else gets a 404. The reader, progress
and notes endpoints are owner-only too (`src/lib/server/owner.ts`, backstopped in
`hooks.server.ts`).
Files are read from `BOOKS_ROOT` (default `/books` when it exists, otherwise `~/books`):
- `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
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 tracker
`/projects/money-tracker` is restricted to administrator accounts. Its tables `/projects/money-tracker` is restricted to administrator accounts. Its tables
@@ -378,7 +402,7 @@ After registering, promote your account (ID shown on `/account`):
```sh ```sh
sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \ sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \
git.stanrunge.dev/stan/personal-website:<deployed-sha> bun scripts/set-admin.ts <account-id> admin git.stanrunge.dev/stan/website:<deployed-sha> bun scripts/set-admin.ts <account-id> admin
``` ```
No public signup grants admin privileges. No public signup grants admin privileges.

View File

@@ -14,9 +14,11 @@
"drizzle-orm": "^1.0.0-rc.4", "drizzle-orm": "^1.0.0-rc.4",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
"jose": "^6.2.10",
"marked": "^18.0.11", "marked": "^18.0.11",
"marked-highlight": "^2.2.4", "marked-highlight": "^2.2.4",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"pdfjs-dist": "^6.4.299",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"topojson-client": "^3.1.0", "topojson-client": "^3.1.0",
"world-atlas": "^2.0.2", "world-atlas": "^2.0.2",
@@ -325,6 +327,30 @@
"@melt-ui/svelte": ["@melt-ui/svelte@0.76.2", "", { "dependencies": { "@floating-ui/core": "^1.3.1", "@floating-ui/dom": "^1.4.5", "@internationalized/date": "^3.5.0", "dequal": "^2.0.3", "focus-trap": "^7.5.2", "nanoid": "^5.0.4" }, "peerDependencies": { "svelte": ">=3 <5" } }, "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA=="], "@melt-ui/svelte": ["@melt-ui/svelte@0.76.2", "", { "dependencies": { "@floating-ui/core": "^1.3.1", "@floating-ui/dom": "^1.4.5", "@internationalized/date": "^3.5.0", "dequal": "^2.0.3", "focus-trap": "^7.5.2", "nanoid": "^5.0.4" }, "peerDependencies": { "svelte": ">=3 <5" } }, "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA=="],
"@napi-rs/canvas": ["@napi-rs/canvas@1.0.13", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.13", "@napi-rs/canvas-darwin-arm64": "1.0.13", "@napi-rs/canvas-darwin-x64": "1.0.13", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.13", "@napi-rs/canvas-linux-arm64-gnu": "1.0.13", "@napi-rs/canvas-linux-arm64-musl": "1.0.13", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-musl": "1.0.13", "@napi-rs/canvas-win32-arm64-msvc": "1.0.13", "@napi-rs/canvas-win32-x64-msvc": "1.0.13" } }, "sha512-b87ugZpQ5ViwqVBkjervxpwa0GmJPsKHLBNoZN6GpjWeHzTbDUGryriq+2DYW2XbB+xCqSIINQysC0/I87nPbw=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.13", "", { "os": "android", "cpu": "arm64" }, "sha512-trn7AaDzQ+yeeA0ezhkC7cB1ZDPtp4UYk0EvB5F8qtYL9jEVWtVrU30WOpI52DszoTxaTsCgf5PP4ZVaOlOIHQ=="],
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6Y2djEetL8copxfNK6hg7oig/xqTwOchv19hBOm+zBNdKc/02Z3kjZN5wrLzv/l5O04/bAQS6I/AlQpwBlXgPg=="],
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-OZoPwwu4TMMg8GTCFBrg8UFExwQTU/55i3uTC/izDat6PHE+ro9dimyQoOboFf91+NY7Pnn70ScR8f8I8+eZXw=="],
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.13", "", { "os": "linux", "cpu": "arm" }, "sha512-dZSYaKROuswU2eUWG5aBKndNVe68elo1YX93xtRax7q658tj8rYE+HwduovWJgs6qwv1r1/CLR60nDeBbyjvyg=="],
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-W6FzYE8ADHI93In1vi80KBL6btDtAMuj50Ch5ptkWS8xuntyzbR4h5JDUmXoakxZn2yMInksudTEqVSWoFqFGg=="],
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-+6fQ+rR49hkAa4voh2stLIwQ+4t+gmoseM8Bc53/DlMXb73IKymDPWK9A/NtWeqTW4ijWppYuyuxS2hPI8tzNA=="],
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.13", "", { "os": "linux", "cpu": "none" }, "sha512-17XEMO3t9RW/TtHtSFf2AkaSWuCWdzbPeidz7zrOFawHxoLSJCnpTI1hN5aUvrZNnkNNmt/vZVJCh0ryUaik3A=="],
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-lc48IwknVHBsNzXn9Xs5OkUsRX38Ml3Z1aOybQ51mMEzZckm8+4A0qROlyYHEoBPzr1kL3IU8ZUAqDb7wtdX8w=="],
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-bGY1r26S2bs8UNFJcPWcCSNdCFbKep5PATsvtgtC9A5u6SRgr2/jJE/RlhHL5m9ZpixNDhVkLY/5h16Kx5V9YA=="],
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-2vRPBZoaIvJrUkDhZTE2Hn0YrnpJRCWVM3EJACoBKjuWI6WgPxGtUoRMsxRviCsyks8bKTSFdyw14CellnG2qA=="],
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.13", "", { "os": "win32", "cpu": "x64" }, "sha512-DUlW3lZPz63kYsf8mxgyyZuevhlH0T3/BGP9l+NazhB0F1+gL6D4hqZdnhZpw24DlWmTpATDcyYSZlJOi+DdfQ=="],
"@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="], "@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="],
"@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="],
@@ -837,6 +863,8 @@
"pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="], "pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="],
"pdfjs-dist": ["pdfjs-dist@6.4.299", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.10" } }, "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="], "pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="],

View File

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

View File

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

View File

@@ -91,7 +91,7 @@ newer; older versions cannot read this repository's lockfile. Use `bun upgrade`
if needed. if needed.
```sh ```sh
git clone https://git.stanrunge.dev/stan/personal-website ~/house-worker git clone https://git.stanrunge.dev/stan/website ~/house-worker
cd ~/house-worker && bun install --frozen-lockfile cd ~/house-worker && bun install --frozen-lockfile
cat > house.env <<'EOF' cat > house.env <<'EOF'
HOUSE_API_URL=https://stanrunge.dev HOUSE_API_URL=https://stanrunge.dev

View File

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

View File

@@ -0,0 +1,148 @@
CREATE SCHEMA "health";
--> statement-breakpoint
CREATE TABLE "health"."appointments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"date" date NOT NULL,
"type" text DEFAULT 'appointment' NOT NULL,
"provider" text,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "appointments_type_check" CHECK ("type" IN ('appointment','checkup','condition','test','procedure','other')),
CONSTRAINT "appointments_date_check" CHECK ("date" >= DATE '1900-01-01')
);
--> statement-breakpoint
CREATE TABLE "health"."body_measurements" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"date" date NOT NULL,
"weight_kg" numeric(5,2),
"waist_cm" numeric(5,1),
"body_fat_pct" numeric(4,1),
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "body_measurements_any_check" CHECK ("weight_kg" IS NOT NULL OR "waist_cm" IS NOT NULL OR "body_fat_pct" IS NOT NULL),
CONSTRAINT "body_measurements_weight_check" CHECK ("weight_kg" IS NULL OR "weight_kg" BETWEEN 20 AND 400),
CONSTRAINT "body_measurements_waist_check" CHECK ("waist_cm" IS NULL OR "waist_cm" BETWEEN 30 AND 250),
CONSTRAINT "body_measurements_fat_check" CHECK ("body_fat_pct" IS NULL OR "body_fat_pct" BETWEEN 1 AND 75),
CONSTRAINT "body_measurements_date_check" CHECK ("date" >= DATE '1900-01-01')
);
--> statement-breakpoint
CREATE TABLE "health"."medications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"name" text NOT NULL,
"kind" text DEFAULT 'medication' NOT NULL,
"dose" text,
"frequency" text,
"start_date" date,
"stop_date" date,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "medications_name_check" CHECK (length(btrim("name")) > 0),
CONSTRAINT "medications_kind_check" CHECK ("kind" IN ('medication','supplement')),
CONSTRAINT "medications_dates_check" CHECK ("stop_date" IS NULL OR "start_date" IS NULL OR "stop_date" >= "start_date")
);
--> statement-breakpoint
CREATE TABLE "health"."nutrition_daily" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"date" date NOT NULL CONSTRAINT "nutrition_daily_date_key" UNIQUE,
"kcal" integer,
"protein_g" numeric(5,1),
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "nutrition_daily_kcal_check" CHECK ("kcal" IS NULL OR "kcal" BETWEEN 0 AND 20000),
CONSTRAINT "nutrition_daily_protein_check" CHECK ("protein_g" IS NULL OR "protein_g" BETWEEN 0 AND 1000),
CONSTRAINT "nutrition_daily_date_check" CHECK ("date" >= DATE '1900-01-01')
);
--> statement-breakpoint
CREATE TABLE "health"."sleep" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"date" date NOT NULL CONSTRAINT "sleep_date_key" UNIQUE,
"bedtime" timestamp with time zone NOT NULL,
"wake_time" timestamp with time zone NOT NULL,
"hours" numeric(4,2) GENERATED ALWAYS AS (round((extract(epoch from (wake_time - bedtime)) / 3600)::numeric, 2)) STORED,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sleep_order_check" CHECK ("wake_time" > "bedtime"),
CONSTRAINT "sleep_length_check" CHECK ("wake_time" - "bedtime" <= interval '24 hours'),
CONSTRAINT "sleep_date_check" CHECK ("date" >= DATE '1900-01-01')
);
--> statement-breakpoint
CREATE TABLE "health"."vaccinations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"vaccine" text NOT NULL,
"target" text,
"dose_no" integer,
"date" date NOT NULL,
"batch_lot" text,
"provider" text,
"country" text,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "vaccinations_vaccine_check" CHECK (length(btrim("vaccine")) > 0),
CONSTRAINT "vaccinations_dose_no_check" CHECK ("dose_no" IS NULL OR "dose_no" BETWEEN 1 AND 20),
CONSTRAINT "vaccinations_country_check" CHECK ("country" IS NULL OR "country" ~ '^[A-Z]{2}$'),
CONSTRAINT "vaccinations_date_check" CHECK ("date" >= DATE '1900-01-01')
);
--> statement-breakpoint
CREATE TABLE "health"."workouts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"date" date NOT NULL,
"session_id" uuid,
"exercise" text NOT NULL,
"sets" integer,
"reps" integer,
"load_kg" numeric(6,2),
"rpe" numeric(3,1),
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "workouts_exercise_check" CHECK (length(btrim("exercise")) > 0),
CONSTRAINT "workouts_sets_check" CHECK ("sets" IS NULL OR "sets" BETWEEN 1 AND 100),
CONSTRAINT "workouts_reps_check" CHECK ("reps" IS NULL OR "reps" BETWEEN 0 AND 1000),
CONSTRAINT "workouts_load_check" CHECK ("load_kg" IS NULL OR "load_kg" BETWEEN 0 AND 1000),
CONSTRAINT "workouts_rpe_check" CHECK ("rpe" IS NULL OR "rpe" BETWEEN 1 AND 10),
CONSTRAINT "workouts_date_check" CHECK ("date" >= DATE '1900-01-01')
);
--> statement-breakpoint
CREATE INDEX "appointments_date_idx" ON "health"."appointments" ("date");--> statement-breakpoint
CREATE INDEX "body_measurements_date_idx" ON "health"."body_measurements" ("date");--> statement-breakpoint
CREATE INDEX "medications_start_date_idx" ON "health"."medications" ("start_date");--> statement-breakpoint
CREATE INDEX "medications_stop_date_idx" ON "health"."medications" ("stop_date");--> statement-breakpoint
CREATE INDEX "vaccinations_date_idx" ON "health"."vaccinations" ("date");--> statement-breakpoint
CREATE INDEX "workouts_date_idx" ON "health"."workouts" ("date");--> statement-breakpoint
CREATE INDEX "workouts_session_idx" ON "health"."workouts" ("session_id");
-- Hand-written below this line (not generated by drizzle-kit).
-- updated_at triggers, same as every other core table (07_triggers_meta_grants.sql).
DO $$
DECLARE t text;
BEGIN
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
FOREACH t IN ARRAY ARRAY['vaccinations','body_measurements','workouts','nutrition_daily','sleep','medications','appointments'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON health.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END IF;
END $$;
--> statement-breakpoint
-- Privacy: health data is readable/writable by the app role only. Unlike the other core
-- schemas it is deliberately NOT granted to personal_ro, and it is not registered in
-- meta.tables (so the generic list/table GUI never shows it).
-- Grants only run on prod (migrations run as the schema owner `personal`); the dev DB
-- (owned by personal_dev) carries no prod grants, matching db-refresh-dev.sh.
REVOKE ALL ON SCHEMA health FROM PUBLIC;
--> statement-breakpoint
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA health TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA health TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA health
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA health
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_cents" integer;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_type" text;--> statement-breakpoint
ALTER TABLE "money"."accounts" ADD COLUMN "bank_balance_at" timestamp with time zone;

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,6 @@ export default [
} }
}, },
{ {
ignores: ['build/', '.svelte-kit/', 'dist/'] ignores: ['build/', '.svelte-kit/', 'dist/', 'static/vendor/']
} }
]; ];

View File

@@ -92,9 +92,11 @@
"drizzle-orm": "^1.0.0-rc.4", "drizzle-orm": "^1.0.0-rc.4",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
"jose": "^6.2.10",
"marked": "^18.0.11", "marked": "^18.0.11",
"marked-highlight": "^2.2.4", "marked-highlight": "^2.2.4",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"pdfjs-dist": "^6.4.299",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"topojson-client": "^3.1.0", "topojson-client": "^3.1.0",
"world-atlas": "^2.0.2", "world-atlas": "^2.0.2",

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

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

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

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

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

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

View File

@@ -1,37 +1,58 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Refresh the local development database from a fresh production dump. # Refresh the development database (personal_dev) from a fresh production dump.
# #
# Prod is the source of truth for data; dev is a disposable copy of it. # Prod is the source of truth for data; dev is a disposable copy of it.
# #
# Required env (put them in .env.dev-refresh or export them; never commit real values): # Required env (put them in .env.dev-refresh or export them; never commit real values):
# PROD_DB_URL read access to prod, e.g. postgresql://personal:...@127.0.0.1:5440/personal # PROD_DB_URL read access to prod, e.g. postgresql://personal:...@127.0.0.1:5440/personal
# DEV_ADMIN_URL superuser on the dev cluster, maintenance DB, e.g. postgresql://postgres:...@127.0.0.1:5432/postgres # DEV_ADMIN_URL superuser on the dev cluster, maintenance DB, e.g. postgresql://postgres:...@127.0.0.1:5440/postgres
# (not needed with PG_CONTAINER, which uses the container's local superuser)
# Optional: # Optional:
# DEV_DB_NAME target database (default personal_dev) # DEV_DB_NAME target database (default personal_dev)
# DEV_OWNER role that owns every object in the dev DB (default personal_dev). It is the
# role the dev site logs in as, so it can also run migrations there. It must not
# be a member of any prod role; prod's CONNECT is not granted to it.
# PG_DOCKER=1 use the postgres:17-alpine image instead of local pg_dump/pg_restore # PG_DOCKER=1 use the postgres:17-alpine image instead of local pg_dump/pg_restore
# PG_CONTAINER name of the Postgres container (e.g. postgres-db-1): run every tool inside it
# via `docker exec` as the local superuser. URLs are then as seen from inside
# the container, e.g. PROD_DB_URL=postgresql://personal:...@127.0.0.1:5432/personal
# DOCKER docker command (default "docker"; e.g. "sudo -n docker")
# #
# What it does: dump prod (custom format) -> drop + recreate DEV_DB_NAME -> create # What it does: dump prod (custom format) -> drop + recreate DEV_DB_NAME owned by DEV_OWNER ->
# missing roles (NOLOGIN) -> restore with ownership/grants intact -> clear sessions # restore without prod ownership/grants, every object owned by DEV_OWNER -> clear sessions and
# and verification tokens so prod logins can't be reused in dev. It never writes to prod. # verification tokens so prod logins can't be reused in dev. It never writes to prod.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
if [[ -f .env.dev-refresh ]]; then set -a; . ./.env.dev-refresh; set +a; fi if [[ -f .env.dev-refresh ]]; then set -a; . ./.env.dev-refresh; set +a; fi
: "${PROD_DB_URL:?set PROD_DB_URL (source, read-only use)}" : "${PROD_DB_URL:?set PROD_DB_URL (source, read-only use)}"
: "${DEV_ADMIN_URL:?set DEV_ADMIN_URL (superuser URL to the dev cluster maintenance DB)}"
DEV_DB_NAME="${DEV_DB_NAME:-personal_dev}" DEV_DB_NAME="${DEV_DB_NAME:-personal_dev}"
DEV_OWNER="${DEV_OWNER:-personal_dev}"
if [[ ! "$DEV_DB_NAME" =~ ^[a-z_][a-z0-9_]*$ ]]; then DOCKER="${DOCKER:-docker}"
echo "DEV_DB_NAME must be a plain lowercase identifier" >&2 if [[ -n "${PG_CONTAINER:-}" ]]; then
exit 1 DEV_ADMIN_URL="${DEV_ADMIN_URL:-postgresql://postgres@localhost/postgres}"
fi fi
: "${DEV_ADMIN_URL:?set DEV_ADMIN_URL (superuser URL to the dev cluster maintenance DB)}"
for ident in "$DEV_DB_NAME" "$DEV_OWNER"; do
if [[ ! "$ident" =~ ^[a-z_][a-z0-9_]*$ ]]; then
echo "DEV_DB_NAME / DEV_OWNER must be plain lowercase identifiers" >&2
exit 1
fi
done
case "$DEV_DB_NAME" in case "$DEV_DB_NAME" in
personal | personal_prod | postgres | template0 | template1) personal | personal_prod | postgres | template0 | template1)
echo "Refusing to overwrite '$DEV_DB_NAME'" >&2 echo "Refusing to overwrite '$DEV_DB_NAME'" >&2
exit 1 exit 1
;; ;;
esac esac
case "$DEV_OWNER" in
personal | personal_app | personal_ddl | personal_ro | postgres)
echo "Refusing: DEV_OWNER '$DEV_OWNER' is a prod/superuser role" >&2
exit 1
;;
esac
# host:port/db of a URL (no credentials), for the "same database" guard. # host:port/db of a URL (no credentials), for the "same database" guard.
endpoint() { bun -e 'const u=new URL(process.argv[1]); console.log(`${u.hostname}:${u.port||5432}${u.pathname}`)' "$1"; } endpoint() { bun -e 'const u=new URL(process.argv[1]); console.log(`${u.hostname}:${u.port||5432}${u.pathname}`)' "$1"; }
@@ -44,8 +65,12 @@ if [[ "$(endpoint "$dev_url")" == "$prod_endpoint" ]]; then
exit 1 exit 1
fi fi
if [[ "${PG_DOCKER:-0}" == 1 ]]; then # Dump goes to stdout and restore reads stdin, so the same commands work locally,
pg() { docker run --rm -i --network host -v "$work:$work" postgres:17-alpine "$@"; } # in a throwaway postgres image, or inside the Postgres container.
if [[ -n "${PG_CONTAINER:-}" ]]; then
pg() { $DOCKER exec -i -u postgres "$PG_CONTAINER" "$@"; }
elif [[ "${PG_DOCKER:-0}" == 1 ]]; then
pg() { $DOCKER run --rm -i --network host postgres:17-alpine "$@"; }
else else
pg() { "$@"; } pg() { "$@"; }
fi fi
@@ -55,27 +80,30 @@ trap 'rm -rf "$work"' EXIT
dump="$work/prod.dump" dump="$work/prod.dump"
echo "Dumping prod ($prod_endpoint)..." echo "Dumping prod ($prod_endpoint)..."
pg pg_dump --format=custom --no-password --file="$dump" "$PROD_DB_URL" pg pg_dump --format=custom --no-password "$PROD_DB_URL" >"$dump"
[[ -s "$dump" ]] || { echo "Empty dump; aborting before touching $DEV_DB_NAME" >&2; exit 1; }
pg pg_restore --list <"$dump" >/dev/null
echo "Recreating $DEV_DB_NAME..." echo "Recreating $DEV_DB_NAME (owner $DEV_OWNER)..."
pg psql -v ON_ERROR_STOP=1 -q "$DEV_ADMIN_URL" <<SQL pg psql -v ON_ERROR_STOP=1 -q "$DEV_ADMIN_URL" <<SQL
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DEV_DB_NAME' AND pid <> pg_backend_pid(); SELECT count(pg_terminate_backend(pid)) AS terminated_sessions FROM pg_stat_activity WHERE datname = '$DEV_DB_NAME' AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS "$DEV_DB_NAME"; DROP DATABASE IF EXISTS "$DEV_DB_NAME";
DO \$\$ DO \$\$
DECLARE r text;
BEGIN BEGIN
FOREACH r IN ARRAY ARRAY['personal','personal_app','personal_ddl','personal_ro'] LOOP IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$DEV_OWNER') THEN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) THEN EXECUTE format('CREATE ROLE %I NOLOGIN', '$DEV_OWNER');
EXECUTE format('CREATE ROLE %I NOLOGIN', r); RAISE NOTICE 'created role $DEV_OWNER (NOLOGIN; give it a password + LOGIN for dev use)';
RAISE NOTICE 'created role % (NOLOGIN; give it a password + LOGIN for dev use)', r;
END IF; END IF;
END LOOP;
END \$\$; END \$\$;
CREATE DATABASE "$DEV_DB_NAME" OWNER personal; CREATE DATABASE "$DEV_DB_NAME" OWNER "$DEV_OWNER";
REVOKE CONNECT, TEMPORARY ON DATABASE "$DEV_DB_NAME" FROM PUBLIC;
SQL SQL
echo "Restoring into $DEV_DB_NAME..." echo "Restoring into $DEV_DB_NAME..."
pg pg_restore --no-password --exit-on-error --dbname="$dev_url" "$dump" # --no-owner/--no-privileges + --role: everything ends up owned by DEV_OWNER and none of
# prod's grants (personal_app, personal_ro, ...) are copied into dev.
pg pg_restore --no-password --exit-on-error --no-owner --no-privileges --role="$DEV_OWNER" \
--dbname="$dev_url" <"$dump"
echo "Clearing sessions and verification tokens..." echo "Clearing sessions and verification tokens..."
pg psql -v ON_ERROR_STOP=1 -q "$dev_url" -c 'TRUNCATE auth.session, auth.verification' pg psql -v ON_ERROR_STOP=1 -q "$dev_url" -c 'TRUNCATE auth.session, auth.verification'

View File

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

View File

@@ -16,8 +16,38 @@ origin=https://stanrunge.dev
environment=/etc/personal-website/prod.env environment=/etc/personal-website/prod.env
environment_name=production environment_name=production
image="git.stanrunge.dev/stan/personal-website:$sha" image="git.stanrunge.dev/stan/website:$sha"
compose="$directory/compose.prod.yml" compose="$directory/compose.prod.yml"
compose_files=(-f "$compose")
# 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.
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
services:
app:
environment:
BOOKS_ROOT: /books
volumes:
- type: bind
source: $books_host_dir
target: /books
read_only: true
YAML
compose_files+=(-f "$directory/compose.books.yml")
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 if ! sudo docker image inspect "$image" >/dev/null 2>&1; then
echo "Image $image is not on this host" >&2; exit 1 echo "Image $image is not on this host" >&2; exit 1
fi fi
@@ -27,7 +57,7 @@ rollback() {
if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then
echo "Health check failed; restoring prior image tag $previous_tag" >&2 echo "Health check failed; restoring prior image tag $previous_tag" >&2
printf 'IMAGE_TAG=%s\n' "$previous_tag" | sudo tee "$directory/.env" >/dev/null printf 'IMAGE_TAG=%s\n' "$previous_tag" | sudo tee "$directory/.env" >/dev/null
sudo docker compose --env-file "$directory/.env" -f "$compose" up -d sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" up -d
fi fi
} }
trap rollback ERR trap rollback ERR
@@ -85,9 +115,9 @@ sudo docker run --rm --network backend --env-file "$environment" "$image" \
printf 'IMAGE_TAG=%s\n' "$sha" | sudo tee "$directory/.env" >/dev/null printf 'IMAGE_TAG=%s\n' "$sha" | sudo tee "$directory/.env" >/dev/null
switched=1 switched=1
sudo docker compose --env-file "$directory/.env" -f "$compose" up -d sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" up -d
container=$(sudo docker compose --env-file "$directory/.env" -f "$compose" ps -q app) container=$(sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" ps -q app)
expected_image=$(sudo docker image inspect -f '{{.Id}}' "$image") expected_image=$(sudo docker image inspect -f '{{.Id}}' "$image")
actual_image=$(sudo docker inspect -f '{{.Image}}' "$container") actual_image=$(sudo docker inspect -f '{{.Image}}' "$container")
if [ "$actual_image" != "$expected_image" ]; then if [ "$actual_image" != "$expected_image" ]; then

72
scripts/health-import.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* Import health data (JSON or CSV) into the private `health` schema.
*
* bun scripts/health-import.ts data.json # {"vaccinations":[...],"sleep":[...]}
* bun scripts/health-import.ts --table vaccinations rivm.csv # CSV (header row) or a JSON array
* bun scripts/health-import.ts --table sleep - < sleep.csv # stdin
* add --dry-run to validate without writing
*
* Column names are the snake_case DB columns (see src/lib/health/tables.ts). Rows with an
* `id` update that row; nutrition_daily and sleep upsert on `date`; everything else is
* inserted (importing the same file twice duplicates those rows). All-or-nothing: if any
* row is invalid, nothing is written. Uses DB_URL (the app role on prod, personal_dev on dev).
*/
import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { parseCsv, toPayload, validatePayload, writeRows } from '../src/lib/server/health-import';
let dryRun = false;
let table: string | undefined;
const files: string[] = [];
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i];
if (arg === '--dry-run') dryRun = true;
else if (arg === '--table') table = process.argv[++i];
else if (arg.startsWith('--table=')) table = arg.slice(8);
else files.push(arg);
}
if (!files.length) {
console.error(
'usage: bun scripts/health-import.ts [--table <name>] [--dry-run] <file.json|file.csv|->...'
);
process.exit(2);
}
const read = (file: string) => (file === '-' ? Bun.stdin.text() : readFile(file, 'utf8'));
const merged: Record<string, unknown[]> = {};
try {
for (const file of files) {
const text = await read(file);
const csv = file.toLowerCase().endsWith('.csv') || (file === '-' && !/^\s*[[{]/.test(text));
const payload = toPayload(csv ? parseCsv(text) : JSON.parse(text), table);
for (const [t, rows] of Object.entries(payload)) (merged[t] ??= []).push(...(rows ?? []));
}
} catch (e) {
console.error(e instanceof Error ? e.message : e);
process.exit(1);
}
const { clean, errors } = validatePayload(toPayload(merged));
if (errors.length) {
for (const e of errors.slice(0, 50)) console.error(`${e.table} row ${e.row}: ${e.error}`);
console.error(`${errors.length} invalid row(s); nothing imported.`);
process.exit(1);
}
const summary = Object.entries(clean)
.map(([t, r]) => `${t}: ${r.length}`)
.join(', ');
if (dryRun) {
console.log(`Valid (dry run, nothing written): ${summary}`);
process.exit(0);
}
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
try {
const counts = await writeRows(drizzle({ client }), clean);
for (const [t, c] of Object.entries(counts))
console.log(`${t}: ${c.inserted} inserted, ${c.updated} updated`);
} finally {
await client.end();
}

View File

@@ -1,6 +1,8 @@
import type { Handle } from '@sveltejs/kit/hooks'; import type { Handle } from '@sveltejs/kit/hooks';
import { building } from '$app/env'; import { building } from '$app/env';
import { auth } from '#lib/server/auth.js'; import { auth } from '#lib/server/auth.js';
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
import { error } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
// Match the endpoint path here: the dev proxy may terminate HTTPS before Vite. // Match the endpoint path here: the dev proxy may terminate HTTPS before Vite.
@@ -9,5 +11,20 @@ export const handle: Handle = async ({ event, resolve }) => {
const session = building ? null : await auth.api.getSession({ headers: event.request.headers }); const session = building ? null : await auth.api.getSession({ headers: event.request.headers });
event.locals.user = session?.user ?? null; event.locals.user = session?.user ?? null;
event.locals.session = session?.session ?? null; event.locals.session = session?.session ?? null;
return resolve(event); // Owner-only areas (health tracker, book files, reader and book APIs). Page loads,
// __data.json and endpoints 404 for non-owners in the route itself (layout load /
// endpoint), which renders the same 404 page as an unknown URL. As a backstop, any other
// method (form actions, which skip layout loads) is refused here before a route runs,
// including writes to the otherwise public /trackers/books pages.
const ownerOnly = isOwnerOnlyPath(event.url.pathname);
const owner = isOwner(event);
const read = event.request.method === 'GET' || event.request.method === 'HEAD';
if (!owner && ((ownerOnly && !read) || (!read && isOwnerWritePath(event.url.pathname))))
error(404, 'Not Found');
const response = await resolve(event);
if (ownerOnly) {
response.headers.set('x-robots-tag', 'noindex, nofollow, noarchive');
response.headers.set('cache-control', 'private, no-store');
}
return response;
}; };

View File

@@ -0,0 +1,133 @@
<script lang="ts">
// A book cover: the real image when there is one, otherwise a generated cover in a
// colour derived from the title (also used when the image fails to load).
import { authorLine, coverHue } from './shared.js';
let {
src,
title,
authors = [],
placeholder = false,
size = 'md',
class: className = ''
}: {
src: string | null;
title: string;
authors?: readonly string[];
placeholder?: boolean;
size?: 'sm' | 'md' | 'lg';
class?: string;
} = $props();
let failed = $state<string | null>(null);
const showImage = $derived(!!src && failed !== src);
const hue = $derived(coverHue(title));
</script>
<div
class={['cover', `cover-${size}`, placeholder && 'is-placeholder', className]}
style:--hue={hue}
>
{#if showImage}
<img
{src}
alt=""
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
onerror={() => (failed = src)}
/>
{:else}
<div class="generated" aria-hidden="true">
<span class="g-title">{title}</span>
{#if authors.length}<span class="g-author">{authorLine(authors)}</span>{/if}
</div>
{/if}
</div>
<style>
.cover {
position: relative;
aspect-ratio: 2 / 3;
width: 100%;
overflow: hidden;
container-type: inline-size;
border-radius: 0.375rem 0.625rem 0.625rem 0.375rem;
background: var(--site-hover);
box-shadow:
0 1px 2px rgb(0 0 0 / 0.25),
0 8px 24px -12px rgb(0 0 0 / 0.5);
}
/* Spine highlight, like a real paperback. */
.cover::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
90deg,
rgb(0 0 0 / 0.28) 0,
rgb(255 255 255 / 0.12) 2.5%,
rgb(0 0 0 / 0.08) 5%,
transparent 9%
);
border-radius: inherit;
}
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.generated {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 14% 10% 10% 14%;
color: hsl(var(--hue) 30% 96%);
background:
radial-gradient(
120% 80% at 100% 0%,
hsl(calc(var(--hue) + 40) 60% 45% / 0.55),
transparent 60%
),
linear-gradient(160deg, hsl(var(--hue) 45% 32%), hsl(calc(var(--hue) + 20) 50% 16%));
}
.g-title {
font-family: Georgia, 'Times New Roman', serif;
font-weight: 600;
line-height: 1.15;
/* Scale with the cover's width so thumbnails don't break words mid-way. */
font-size: clamp(0.55rem, 12cqi, 1.6rem);
display: -webkit-box;
-webkit-line-clamp: 6;
line-clamp: 6;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: break-word;
hyphens: auto;
}
.g-author {
font-size: clamp(0.5rem, 6.5cqi, 0.85rem);
letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.8;
}
@container (max-width: 90px) {
.g-author {
display: none;
}
}
.is-placeholder {
box-shadow: none;
outline: 1.5px dashed color-mix(in srgb, var(--site-muted) 60%, transparent);
outline-offset: 2px;
}
.is-placeholder img,
.is-placeholder .generated {
filter: grayscale(0.85) opacity(0.55);
}
</style>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
// 1-5 star rating. Read-only by default; with `name` it renders radio buttons for a form.
import Star from 'lucide-svelte/icons/star';
let {
value,
size = 14,
name,
onchange
}: {
value: number | null;
size?: number;
name?: string;
onchange?: (value: number | null) => void;
} = $props();
let hover = $state<number | null>(null);
const shown = $derived(hover ?? value ?? 0);
</script>
{#if name || onchange}
<span class="stars interactive" role="radiogroup" aria-label="Rating">
{#each [1, 2, 3, 4, 5] as n (n)}
<button
type={name ? 'submit' : 'button'}
{name}
value={value === n ? '' : String(n)}
role="radio"
aria-checked={value === n}
aria-label={`${n} star${n > 1 ? 's' : ''}${value === n ? ' (click to clear)' : ''}`}
onpointerenter={() => (hover = n)}
onpointerleave={() => (hover = null)}
onclick={() => onchange?.(value === n ? null : n)}
>
<Star {size} class={n <= shown ? 'on' : 'off'} />
</button>
{/each}
</span>
{:else if value}
<span class="stars" aria-label={`${value} out of 5 stars`} title={`${value}/5`}>
{#each [1, 2, 3, 4, 5] as n (n)}
<Star {size} class={n <= value ? 'on' : 'off'} aria-hidden="true" />
{/each}
</span>
{/if}
<style>
.stars {
display: inline-flex;
align-items: center;
gap: 1px;
}
.stars :global(.on) {
fill: #f5b301;
color: #f5b301;
}
.stars :global(.off) {
color: color-mix(in srgb, var(--site-muted) 50%, transparent);
}
.interactive button {
padding: 2px;
border-radius: 4px;
line-height: 0;
}
.interactive button:hover {
transform: scale(1.12);
}
</style>

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { pagesRead, parseProgress } from './progress';
import { parsePdfLocator, pdfLocator } from './shared';
describe('progress payload', () => {
it('accepts a reader save and rounds the percent', () => {
expect(
parseProgress({
fileId: 3,
locator: 'epubcfi(/6/4!/4/2)',
percent: 12.34567,
sessionId: null
})
).toMatchObject({ fileId: 3, locator: 'epubcfi(/6/4!/4/2)', percent: 12.35 });
});
it.each([
null,
'nope',
{},
{ percent: -1 },
{ percent: 101 },
{ percent: 'ten' },
{ percent: 10, page: 1.5 },
{ percent: 10, fileId: -2 },
{ percent: 10, locator: 'x'.repeat(2001) }
])('rejects %j', (body) => {
expect(parseProgress(body)).toBeNull();
});
});
describe('pages read', () => {
it('uses the page delta when pages are known', () => {
expect(pagesRead({ startPage: 10, endPage: 34 })).toBe(24);
});
it('scales the percent delta by the page count', () => {
expect(pagesRead({ startPercent: 10, endPercent: 20, pageCount: 300 })).toBe(30);
});
it('never goes negative and needs enough data', () => {
expect(pagesRead({ startPage: 50, endPage: 20 })).toBe(0);
expect(pagesRead({ startPercent: 40, endPercent: 30, pageCount: 100 })).toBe(0);
expect(pagesRead({ startPercent: 10, endPercent: 20 })).toBeNull();
});
});
describe('pdf locators', () => {
it('round-trips pages and rects', () => {
const loc = pdfLocator(12, [{ x: 0.1, y: 0.2, w: 0.3333333, h: 0.02 }]);
expect(loc).toBe('page:12#r=0.1,0.2,0.3333,0.02');
expect(parsePdfLocator(loc)).toEqual({
page: 12,
rects: [{ x: 0.1, y: 0.2, w: 0.3333, h: 0.02 }]
});
expect(parsePdfLocator('page:3')).toEqual({ page: 3, rects: [] });
expect(parsePdfLocator('epubcfi(/6/2)')).toBeNull();
});
});

41
src/lib/books/progress.ts Normal file
View File

@@ -0,0 +1,41 @@
// Validation and maths for the reader's progress API (pure, unit tested).
import { z } from 'zod';
export const progressPayload = z.object({
fileId: z.number().int().positive().nullish(),
locator: z.string().max(2000).nullish(),
page: z.number().int().min(0).max(100000).nullish(),
percent: z.number().min(0).max(100),
sessionId: z.number().int().positive().nullish(),
/** Total pages in the opened file (PDF), used for pages-read on sessions. */
pageCount: z.number().int().positive().max(100000).nullish()
});
export type ProgressPayload = z.infer<typeof progressPayload>;
export function parseProgress(body: unknown): ProgressPayload | null {
const result = progressPayload.safeParse(body);
if (!result.success) return null;
const value = result.data;
return { ...value, percent: Math.round(value.percent * 100) / 100 };
}
/**
* Pages read in a session: the page delta when both pages are known (PDF), otherwise the
* percent delta scaled by the book's page count. Never negative (going back isn't reading).
*/
export function pagesRead(input: {
startPage?: number | null;
endPage?: number | null;
startPercent?: number | null;
endPercent?: number | null;
pageCount?: number | null;
}): number | null {
const { startPage, endPage, startPercent, endPercent, pageCount } = input;
if (startPage != null && endPage != null) return Math.max(0, endPage - startPage);
if (startPercent != null && endPercent != null && pageCount)
return Math.max(0, Math.round(((endPercent - startPercent) / 100) * pageCount));
return null;
}
/** A reader session continues if the last save was less than this long ago. */
export const SESSION_GAP_MS = 30 * 60 * 1000;

View File

@@ -0,0 +1,228 @@
<script lang="ts">
// Reflowable books (EPUB, MOBI/AZW3, FB2, CBZ) via the vendored foliate-js.
import { onMount } from 'svelte';
import { highlightCss, type Highlight, type HighlightColor } from '#lib/books/shared.js';
import {
fetchWithProgress,
themeColors,
type EngineProps,
type ReaderSettings,
type ReaderTheme,
type TocItem
} from './types.js';
type FoliateToc = { label?: string; href?: string; subitems?: FoliateToc[] | null };
type Annotation = { value: string; color?: string };
type Renderer = HTMLElement & {
setStyles(css: string): void;
getContents(): { doc: Document; index: number }[];
};
interface FoliateView extends HTMLElement {
book: { toc?: FoliateToc[] };
renderer: Renderer;
open(file: File): Promise<void>;
init(options: { lastLocation?: unknown; showTextStart?: boolean }): Promise<void>;
close(): void;
goTo(target: string | number): Promise<unknown>;
goToFraction(fraction: number): Promise<void>;
goLeft(): Promise<void>;
goRight(): Promise<void>;
addAnnotation(annotation: Annotation): Promise<unknown>;
deleteAnnotation(annotation: Annotation): Promise<unknown>;
getCFI(index: number, range: Range): string;
}
type DrawFn = (rects: unknown, options: Record<string, unknown>) => SVGElement;
let {
url,
name,
start,
startPercent,
highlights,
settings,
theme,
onready,
onrelocate,
onselection,
onhighlightclick,
onkey,
onloading,
onerror
}: EngineProps = $props();
let host = $state<FoliateView>();
let view = $state<FoliateView>();
let highlightDraw: DrawFn | undefined;
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) {
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; }
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; }
p { hyphens: auto; -webkit-hyphens: auto; widows: 2; orphans: 2; }
a:any-link { color: ${c.link} !important; }
${t === 'light' ? '' : 'body *:not(a, a *) { color: inherit !important; background-color: transparent !important; border-color: currentColor; }'}
img, svg, video { max-width: 100%; height: auto; }
aside[epub|type~="footnote"], aside[epub|type~="endnote"] { display: none; }
::selection { background: rgba(250, 204, 21, 0.45); }`;
}
function apply(v: FoliateView) {
const r = v.renderer;
r.setAttribute('flow', settings.flow);
r.setAttribute('margin', '48px');
r.setAttribute('gap', '6%');
r.setAttribute('max-inline-size', '720px');
r.setAttribute('max-column-count', '2');
r.setStyles(css(settings, theme));
v.style.background = themeColors[theme].bg;
}
$effect(() => {
if (view) apply(view);
});
const color = (h: Highlight) =>
highlightCss[(h.color as HighlightColor) ?? 'yellow'] ?? highlightCss.yellow;
const toToc = (items: FoliateToc[] = []): TocItem[] =>
items
.filter((i) => i.href)
.map((i) => ({
label: (i.label ?? '').trim() || 'Untitled',
target: i.href!,
children: toToc(i.subitems ?? [])
}));
export async function goTo(target: string | number) {
await view?.goTo(target);
}
export async function goToPercent(percent: number) {
await view?.goToFraction(Math.min(0.9999, Math.max(0, percent / 100)));
}
export function next() {
void view?.goRight();
}
export function prev() {
void view?.goLeft();
}
export function addHighlight(h: Highlight) {
if (h.locator) void view?.addAnnotation({ value: h.locator, color: color(h) }).catch(() => {});
}
export function removeHighlight(h: Highlight) {
if (h.locator) void view?.deleteAnnotation({ value: h.locator }).catch(() => {});
}
export function clearSelection() {
for (const { doc } of view?.renderer.getContents() ?? []) doc.getSelection()?.removeAllRanges();
}
onMount(() => {
const abort = new AbortController();
let disposed = false;
let el: FoliateView | undefined;
(async () => {
try {
const base = '/vendor/foliate-js';
const [, overlayer] = await Promise.all([
import(/* @vite-ignore */ `${base}/view.js`),
import(/* @vite-ignore */ `${base}/overlayer.js`)
]);
highlightDraw = overlayer.Overlayer.highlight;
onloading(0);
const blob = await fetchWithProgress(url, onloading, abort.signal);
if (disposed) return;
onloading(null);
el = host;
if (!el) return;
el.addEventListener('relocate', (e) => {
const d = (e as CustomEvent).detail;
onrelocate({
percent: Math.max(0, Math.min(100, (d.fraction ?? 0) * 100)),
locator: d.cfi,
page: null,
pageCount: null,
label: d.tocItem?.label?.trim() || null
});
});
el.addEventListener('load', (e) => {
const { doc, index } = (e as CustomEvent).detail as { doc: Document; index: number };
doc.addEventListener('keydown', onkey);
let timer: ReturnType<typeof setTimeout>;
doc.addEventListener('selectionchange', () => {
clearTimeout(timer);
timer = setTimeout(() => {
const sel = doc.getSelection();
const text = sel?.toString().trim();
if (!sel || sel.isCollapsed || !sel.rangeCount || !text) return onselection(null);
try {
onselection({ text, locator: el!.getCFI(index, sel.getRangeAt(0)), page: null });
} catch {
onselection(null);
}
}, 250);
});
});
el.addEventListener('create-overlay', () => {
for (const h of highlights) addHighlight(h);
});
el.addEventListener('draw-annotation', (e) => {
const { draw, annotation } = (e as CustomEvent).detail as {
draw: (fn: DrawFn, opts: Record<string, unknown>) => void;
annotation: Annotation;
};
if (highlightDraw)
draw(highlightDraw, { color: annotation.color ?? highlightCss.yellow });
});
el.addEventListener('show-annotation', (e) => {
const value = (e as CustomEvent).detail.value as string;
const h = highlights.find((x) => x.locator === value);
if (h) onhighlightclick(h.id);
});
await el.open(new File([blob], name, { type: blob.type }));
if (disposed) return;
apply(el);
view = el;
const lastLocation = start ?? (startPercent > 0 ? { fraction: startPercent / 100 } : null);
await el.init({ lastLocation, showTextStart: !lastLocation });
onready(toToc(el.book.toc));
} catch (e) {
if (!disposed && !(e instanceof DOMException && e.name === 'AbortError'))
onerror(e instanceof Error ? e.message : 'Could not open this book');
}
})();
return () => {
disposed = true;
abort.abort();
el?.close?.();
};
});
</script>
<div class="host">
<foliate-view bind:this={host}></foliate-view>
</div>
<style>
.host {
position: absolute;
inset: 0;
}
.host :global(foliate-view) {
display: block;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,541 @@
<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';
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
import {
highlightCss,
parsePdfLocator,
pdfLocator,
type Highlight,
type HighlightColor,
type Rect
} from '#lib/books/shared.js';
import { type EngineProps, type TocItem } from './types.js';
let {
url,
start,
startPercent,
highlights,
settings,
theme,
onready,
onrelocate,
onselection,
onhighlightclick,
onloading,
onerror
}: EngineProps = $props();
type PageInfo = { num: number; width: number; height: number }; // at scale 1
type Live = { task?: RenderTask; el: HTMLElement; layer: HTMLElement };
let scroller: HTMLDivElement;
let pdf: PDFDocumentProxy | undefined;
let pdfjs: typeof import('pdfjs-dist') | undefined;
let pages = $state<PageInfo[]>([]);
let width = $state(0);
let current = 1;
// 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 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 byPage = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
const map = new Map<number, { h: Highlight; rects: Rect[] }[]>();
for (const h of highlights) {
const loc = parsePdfLocator(h.locator);
if (!loc || !loc.rects.length) continue;
const list = map.get(loc.page) ?? [];
list.push({ h, rects: loc.rects });
map.set(loc.page, list);
}
return map;
});
const color = (h: Highlight) =>
highlightCss[(h.color as HighlightColor) ?? 'yellow'] ?? highlightCss.yellow;
async function render(num: number) {
const el = pageEls.get(num);
if (!pdf || !pdfjs || !el || live.has(num)) return;
const layer = el.querySelector<HTMLElement>('.layer')!;
const entry: Live = { el, layer };
live.set(num, entry);
const page: PDFPageProxy = await pdf.getPage(num);
if (live.get(num) !== entry) return;
const s = scale;
const viewport = page.getViewport({ scale: s });
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const canvas = document.createElement('canvas');
canvas.width = Math.floor(viewport.width * dpr);
canvas.height = Math.floor(viewport.height * dpr);
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,
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined
});
try {
await entry.task.promise;
if (live.get(num) !== entry) return;
await new pdfjs.TextLayer({
textContentSource: page.streamTextContent(),
container: text,
viewport
}).render();
} catch {
// cancelled (scrolled away or re-zoomed)
}
}
function unrender(num: number) {
const entry = live.get(num);
if (!entry) return;
entry.task?.cancel();
entry.layer.replaceChildren();
live.delete(num);
}
function page(node: HTMLElement, num: number) {
pageEls.set(num, node);
observer?.observe(node);
return {
destroy() {
observer?.unobserve(node);
pageEls.delete(num);
unrender(num);
}
};
}
// Re-render everything when the zoom or the width changes, keeping the reading position.
let lastScale = 0;
$effect(() => {
const s = scale;
// The first scale with pages loaded is the baseline; the initial jump to the saved
// position happens in onMount and must not be undone by a "keep position" here.
if (!pages.length || !width) return;
if (!lastScale) {
lastScale = s;
return;
}
if (Math.abs(s - lastScale) < 0.001) return;
lastScale = s;
const keep = position();
for (const num of [...live.keys()]) unrender(num);
requestAnimationFrame(() => {
scrollToPosition(keep.page, keep.fraction);
for (const [num, node] of pageEls) {
const r = node.getBoundingClientRect();
const v = scroller.getBoundingClientRect();
if (r.bottom > v.top - v.height && r.top < v.bottom + v.height) void render(num);
}
});
});
function position() {
if (!scroller) return { page: 1, fraction: 0 };
const anchor = scroller.scrollTop + scroller.clientHeight * 0.3;
for (const p of pages) {
const node = pageEls.get(p.num);
if (!node) continue;
const top = node.offsetTop;
const bottom = top + node.offsetHeight;
if (anchor < bottom + 8)
return {
page: p.num,
fraction: Math.max(0, Math.min(1, (anchor - top) / node.offsetHeight))
};
}
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;
scroller.scrollTop =
node.offsetTop + node.offsetHeight * fraction - (fraction ? scroller.clientHeight * 0.3 : 12);
}
let frame = 0;
function onscroll() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const { page: num, fraction } = position();
current = num;
onrelocate({
percent: Math.min(100, ((num - 1 + fraction) / pages.length) * 100),
locator: pdfLocator(num),
page: num,
pageCount: pages.length,
label: labelFor(num)
});
});
}
let flatToc: { page: number; label: string }[] = [];
const labelFor = (num: number) => {
let label: string | null = null;
for (const item of flatToc) if (item.page <= num) label = item.label;
return label;
};
export async function goTo(target: string | number) {
if (typeof target === 'number') return scrollToPosition(target);
const loc = parsePdfLocator(target);
if (!loc) return;
const y = loc.rects[0]?.y;
if (y != null) scrollToPosition(loc.page, Math.max(0, y - 0.05) || 0.0001);
else scrollToPosition(loc.page);
}
export async function goToPercent(percent: number) {
const p = (percent / 100) * pages.length;
scrollToPosition(Math.floor(p) + 1, p % 1 || 0);
}
export function next() {
scrollToPosition(current + 1);
}
export function prev() {
const { fraction } = position();
scrollToPosition(fraction > 0.1 ? current : current - 1);
}
export function addHighlight() {
// Highlights are derived from props; nothing to draw imperatively.
}
export function removeHighlight() {}
export function clearSelection() {
document.getSelection()?.removeAllRanges();
}
/** Selection rects as fractions of the page, merged per line. */
function selectionRects(range: Range, pageEl: HTMLElement): Rect[] {
const box = pageEl.getBoundingClientRect();
const rects = [...range.getClientRects()]
.filter((r) => r.width > 1 && r.height > 1)
.map((r) => ({
x: (r.left - box.left) / box.width,
y: (r.top - box.top) / box.height,
w: r.width / box.width,
h: r.height / box.height
}))
.filter((r) => r.x >= -0.01 && r.y >= -0.01 && r.x < 1 && r.y < 1)
.sort((a, b) => a.y - b.y || a.x - b.x);
const merged: Rect[] = [];
for (const r of rects) {
const last = merged.at(-1);
if (last && Math.abs(last.y - r.y) < last.h * 0.5 && r.x <= last.x + last.w + 0.02) {
const right = Math.max(last.x + last.w, r.x + r.w);
const bottom = Math.max(last.y + last.h, r.y + r.h);
last.x = Math.min(last.x, r.x);
last.y = Math.min(last.y, r.y);
last.w = right - last.x;
last.h = bottom - last.y;
} else merged.push({ ...r });
}
return merged.slice(0, 60);
}
function hitHighlight(e: MouseEvent, num: number) {
if (!document.getSelection()?.isCollapsed) return;
const box = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = (e.clientX - box.left) / box.width;
const y = (e.clientY - box.top) / box.height;
const hit = byPage
.get(num)
?.find(({ rects }) =>
rects.some((r) => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h)
);
if (hit) onhighlightclick(hit.h.id);
}
async function resolveToc(doc: PDFDocumentProxy): Promise<TocItem[]> {
const outline = await doc.getOutline().catch(() => null);
if (!outline) return [];
let budget = 600;
const walk = async (items: typeof outline): Promise<TocItem[]> => {
const out: TocItem[] = [];
for (const item of items) {
if (budget-- <= 0) break;
let num: number | null = null;
try {
const dest =
typeof item.dest === 'string' ? await doc.getDestination(item.dest) : item.dest;
const ref = dest?.[0];
if (ref && typeof ref === 'object') num = (await doc.getPageIndex(ref)) + 1;
else if (typeof ref === 'number') num = ref + 1;
} catch {
num = null;
}
const children = item.items?.length ? await walk(item.items) : [];
if (num) {
out.push({ label: item.title.trim() || `Page ${num}`, target: num, children });
flatToc.push({ page: num, label: item.title.trim() });
} else out.push(...children);
}
return out;
};
const toc = await walk(outline);
flatToc = flatToc.sort((a, b) => a.page - b.page);
return toc;
}
onMount(() => {
let disposed = false;
const resize = new ResizeObserver(() => (width = scroller.clientWidth));
resize.observe(scroller);
width = scroller.clientWidth;
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const num = Number((entry.target as HTMLElement).dataset.page);
if (entry.isIntersecting) void render(num);
else unrender(num);
}
},
{ root: scroller, rootMargin: '150% 0px' }
);
let selTimer: ReturnType<typeof setTimeout>;
const onSelectionChange = () => {
clearTimeout(selTimer);
selTimer = setTimeout(() => {
const sel = document.getSelection();
const text = sel?.toString().trim();
const node = sel?.anchorNode;
const pageEl = (node instanceof Element ? node : node?.parentElement)?.closest<HTMLElement>(
'.pdf-page'
);
if (!sel || sel.isCollapsed || !text || !pageEl || !scroller.contains(pageEl))
return onselection(null);
const num = Number(pageEl.dataset.page);
const rects = selectionRects(sel.getRangeAt(0), pageEl);
if (!rects.length) return onselection(null);
onselection({ text, locator: pdfLocator(num, rects), page: num });
}, 250);
};
document.addEventListener('selectionchange', onSelectionChange);
(async () => {
try {
pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
onloading(0);
const task = 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();
pdf = doc;
onloading(null);
// Use the first page's size for every page up front; real sizes fill in quickly.
const first = (await doc.getPage(1)).getViewport({ scale: 1 });
pages = Array.from({ length: doc.numPages }, (_, i) => ({
num: i + 1,
width: first.width,
height: first.height
}));
void (async () => {
for (let i = 2; i <= Math.min(doc.numPages, 2000) && !disposed; i++) {
const vp = (await doc.getPage(i)).getViewport({ scale: 1 });
if (vp.width !== first.width || vp.height !== first.height)
pages[i - 1] = { num: i, width: vp.width, height: vp.height };
}
})();
requestAnimationFrame(async () => {
const loc = parsePdfLocator(start);
if (loc) scrollToPosition(loc.page, loc.rects[0]?.y ?? 0);
else if (startPercent > 0) await goToPercent(startPercent);
onscroll();
onready(await resolveToc(doc));
});
} catch (e) {
if (!disposed) onerror(e instanceof Error ? e.message : 'Could not open this PDF');
}
})();
return () => {
disposed = true;
document.removeEventListener('selectionchange', onSelectionChange);
resize.disconnect();
observer?.disconnect();
for (const num of [...live.keys()]) unrender(num);
void pdf?.destroy();
};
});
</script>
<div
class={['scroller', `t-${theme}`]}
style:background={theme === 'light' ? '#e9e9e7' : theme === 'sepia' ? '#e6dcc4' : '#0b0b0c'}
bind:this={scroller}
{onscroll}
>
<div class="pages">
{#each pages as p (p.num)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="pdf-page"
data-page={p.num}
style:width={`${Math.floor(p.width * scale)}px`}
style:height={`${Math.floor(p.height * scale)}px`}
use:page={p.num}
onclick={(e) => hitHighlight(e, p.num)}
>
<div class="layer"></div>
{#each byPage.get(p.num) ?? [] as { h, rects } (h.id)}
{#each rects as r, i (i)}
<span
class="hl"
style:left={`${r.x * 100}%`}
style:top={`${r.y * 100}%`}
style:width={`${r.w * 100}%`}
style:height={`${r.h * 100}%`}
style:background={color(h)}
></span>
{/each}
{/each}
<span class="num">{p.num}</span>
</div>
{/each}
</div>
</div>
<style>
.scroller {
position: absolute;
inset: 0;
overflow: auto;
overscroll-behavior: contain;
}
.pages {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 12px 12px 96px;
min-width: fit-content;
}
.pdf-page {
position: relative;
background: white;
box-shadow:
0 1px 3px rgb(0 0 0 / 0.12),
0 8px 24px rgb(0 0 0 / 0.08);
flex: none;
}
.t-sepia .pdf-page :global(canvas) {
filter: sepia(0.35) brightness(0.97);
}
.t-dark .pdf-page {
background: #1a1a1c;
}
.t-dark .pdf-page :global(canvas) {
filter: invert(0.9) hue-rotate(180deg);
}
.layer,
.layer :global(canvas) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.hl {
position: absolute;
mix-blend-mode: multiply;
opacity: 0.45;
border-radius: 2px;
pointer-events: none;
z-index: 1;
}
.t-dark .hl {
mix-blend-mode: screen;
opacity: 0.35;
}
.num {
position: absolute;
right: -2.25rem;
bottom: 0;
font-size: 0.7rem;
color: rgb(128 128 128 / 0.8);
font-variant-numeric: tabular-nums;
}
@media (max-width: 1100px) {
.num {
display: none;
}
}
/* pdf.js text layer (from pdfjs-dist/web/pdf_viewer.css, trimmed) */
.pdf-page :global(.textLayer) {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 2;
--min-font-size: 1;
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
--min-font-size-inv: calc(1 / var(--min-font-size));
--scale-round-x: 1px;
--scale-round-y: 1px;
}
.pdf-page :global(.textLayer :is(span, br)) {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
user-select: text;
}
.pdf-page :global(.textLayer > :not(.markedContent)),
.pdf-page :global(.textLayer .markedContent span:not(.markedContent)) {
z-index: 1;
--font-height: 0;
font-size: calc(var(--text-scale-factor) * var(--font-height));
--scale-x: 1;
--rotate: 0deg;
transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
}
.pdf-page :global(.textLayer .markedContent) {
display: contents;
}
.pdf-page :global(.textLayer ::selection) {
background: rgb(250 204 21 / 0.4);
color: transparent;
}
.pdf-page :global(.textLayer br::selection) {
background: transparent;
}
.pdf-page :global(.textLayer .endOfContent) {
display: block;
position: absolute;
inset: 100% 0 0;
z-index: 0;
cursor: default;
user-select: none;
}
</style>

View File

@@ -0,0 +1,89 @@
// Contract between the reader shell (the /read page) and the EPUB/PDF engines.
import type { Highlight } from '#lib/books/shared.js';
export type TocItem = { label: string; target: string | number; children: TocItem[] };
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
pageCount: number | null;
label: string | null; // current chapter
};
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
};
export const themeColors: Record<ReaderTheme, { bg: string; fg: string; link: string }> = {
light: { bg: '#fdfdfb', fg: '#1c1c1c', link: '#2563eb' },
sepia: { bg: '#f4ecd8', fg: '#433422', link: '#8a4b14' },
dark: { bg: '#121214', fg: '#d8d8d8', link: '#7aa8ff' }
};
/** What both engines expose to the shell through bind:this. */
export interface ReaderEngine {
goTo(target: string | number): Promise<void>;
goToPercent(percent: number): Promise<void>;
next(): void;
prev(): void;
addHighlight(h: Highlight): void;
removeHighlight(h: Highlight): void;
clearSelection(): void;
}
export type EngineProps = {
url: string;
name: string;
start: string | null;
startPercent: number;
highlights: Highlight[];
settings: ReaderSettings;
theme: ReaderTheme;
onready: (toc: TocItem[]) => void;
onrelocate: (r: Relocation) => void;
onselection: (s: TextSelection | null) => void;
onhighlightclick: (id: number) => void;
onkey: (e: KeyboardEvent) => void;
onloading: (fraction: number | null) => void;
onerror: (message: string) => void;
};
/** Downloads a file with progress, so big books show a loading bar instead of a blank page. */
export async function fetchWithProgress(
url: string,
onprogress: (fraction: number | null) => void,
signal?: AbortSignal
): Promise<Blob> {
const res = await fetch(url, { signal, credentials: 'same-origin' });
if (!res.ok) throw new Error(res.status === 404 ? 'File not found' : `HTTP ${res.status}`);
const total = Number(res.headers.get('content-length')) || 0;
if (!res.body || !total) return res.blob();
const reader = res.body.getReader();
const chunks: Uint8Array<ArrayBuffer>[] = [];
let loaded = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.byteLength;
onprogress(Math.min(1, loaded / total));
}
return new Blob(chunks, { type: res.headers.get('content-type') ?? '' });
}

104
src/lib/books/shared.ts Normal file
View File

@@ -0,0 +1,104 @@
// Shared (client + server) types and helpers for the book tracker.
export const readingStatuses = ['want_to_read', 'reading', 'finished', 'abandoned'] as const;
export type ReadingStatus = (typeof readingStatuses)[number];
export const statusLabels: Record<ReadingStatus, string> = {
want_to_read: 'want to read',
reading: 'reading',
finished: 'finished',
abandoned: 'abandoned'
};
export function isReadingStatus(value: unknown): value is ReadingStatus {
return typeof value === 'string' && (readingStatuses as readonly string[]).includes(value);
}
/** Formats the in-browser reader can open. MOBI/AZW3/FB2/CBZ go through foliate-js too. */
export const readableFormats = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'fb2', 'cbz'] as const;
export function isReadable(format: string): boolean {
return (readableFormats as readonly string[]).includes(format.toLowerCase());
}
/** Preferred file to open when a work has several formats. */
export const formatPreference = ['epub', 'azw3', 'mobi', 'azw', 'fb2', 'pdf', 'cbz'];
/** One card in the library. Public fields only, unless noted. */
export type BookCard = {
id: number;
title: string;
subtitle: string | null;
authors: string[];
year: number | null;
tags: string[];
pageCount: number | null;
cover: string | null; // URL (our cover endpoint or a remote cover), or null for a generated one
status: ReadingStatus | null; // null: never touched
percent: number;
rating: number | null;
lastReadAt: string | null;
finishedAt: string | null;
addedAt: string;
// Owner only (empty / false for everyone else):
formats: string[];
hasFile: boolean;
};
export type LibraryStats = {
total: number;
finishedThisYear: number;
pagesThisYear: number;
reading: number;
year: number;
};
export type Highlight = {
id: number;
fileId: number | null;
locator: string | null;
page: number | null;
highlight: string | null;
note: string | null;
color: string | null;
createdAt: string;
};
export const highlightColors = ['yellow', 'green', 'blue', 'pink', 'purple'] as const;
export type HighlightColor = (typeof highlightColors)[number];
export const highlightCss: Record<HighlightColor, string> = {
yellow: '#facc15',
green: '#4ade80',
blue: '#60a5fa',
pink: '#f472b6',
purple: '#c084fc'
};
/** Deterministic hue for generated covers, so a book always gets the same colour. */
export function coverHue(title: string): number {
let hash = 0;
for (const char of title) hash = (hash * 31 + char.codePointAt(0)!) >>> 0;
return hash % 360;
}
export function authorLine(authors: readonly string[]): string {
if (authors.length <= 2) return authors.join(' & ');
return `${authors[0]} et al.`;
}
/** PDF highlight locators: `page:12` or `page:12#r=x,y,w,h;x,y,w,h` (rects as page fractions). */
export type Rect = { x: number; y: number; w: number; h: number };
export function pdfLocator(page: number, rects: Rect[] = []): string {
const r = rects
.map((rect) => [rect.x, rect.y, rect.w, rect.h].map((n) => +n.toFixed(4)).join(','))
.join(';');
return r ? `page:${page}#r=${r}` : `page:${page}`;
}
export function parsePdfLocator(locator: string | null): { page: number; rects: Rect[] } | null {
const match = /^page:(\d+)(?:#r=([\d.,;-]+))?$/.exec(locator ?? '');
if (!match) return null;
const rects = (match[2] ?? '')
.split(';')
.filter(Boolean)
.map((part) => part.split(',').map(Number))
.filter((n) => n.length === 4 && n.every(Number.isFinite))
.map(([x, y, w, h]) => ({ x, y, w, h }));
return { page: Number(match[1]), rects };
}

View File

@@ -8,10 +8,12 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import BookOpen from 'lucide-svelte/icons/book-open';
import CandlestickChart from 'lucide-svelte/icons/candlestick-chart'; import CandlestickChart from 'lucide-svelte/icons/candlestick-chart';
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined'; import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
import FolderKanban from 'lucide-svelte/icons/folder-kanban'; import FolderKanban from 'lucide-svelte/icons/folder-kanban';
import Gamepad2 from 'lucide-svelte/icons/gamepad-2'; import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
import HeartPulse from 'lucide-svelte/icons/heart-pulse';
import House from 'lucide-svelte/icons/house'; import House from 'lucide-svelte/icons/house';
import Globe2 from 'lucide-svelte/icons/globe-2'; import Globe2 from 'lucide-svelte/icons/globe-2';
import Languages from 'lucide-svelte/icons/languages'; import Languages from 'lucide-svelte/icons/languages';
@@ -34,7 +36,9 @@
'money tracker': Wallet, 'money tracker': Wallet,
'cs prices': CandlestickChart, 'cs prices': CandlestickChart,
'house tracker': House, 'house tracker': House,
lists: ListChecks lists: ListChecks,
health: HeartPulse,
books: BookOpen
}; };
const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban); const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban);

View File

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

View File

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

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

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

View File

@@ -0,0 +1,118 @@
<script lang="ts">
// Weight + waist trend. Same lazy-loaded ECharts setup as the CS prices chart.
import { onMount } from 'svelte';
import type { ECharts } from 'echarts/core';
export type BodyPoint = { date: string; weightKg: number | null; waistCm: number | null };
let { points }: { points: BodyPoint[] } = $props();
let element: HTMLDivElement;
let chart: ECharts | undefined;
let ready = $state(false);
const series = $derived([
{
name: 'Weight (kg)',
yAxisIndex: 0,
data: points.filter((p) => p.weightKg !== null).map((p) => [Date.parse(p.date), p.weightKg])
},
{
name: 'Waist (cm)',
yAxisIndex: 1,
data: points.filter((p) => p.waistCm !== null).map((p) => [Date.parse(p.date), p.waistCm])
}
]);
function update() {
if (!chart) return;
const style = getComputedStyle(document.documentElement);
const foreground = style.getPropertyValue('--site-text').trim();
const muted = style.getPropertyValue('--site-muted').trim();
const border = style.getPropertyValue('--site-border').trim();
const dark = style.getPropertyValue('--site-bg').trim() === '#000';
const axis = (name: string) => ({
type: 'value' as const,
name,
scale: true,
nameTextStyle: { color: muted },
axisLabel: { color: muted },
splitLine: { lineStyle: { color: border } }
});
chart.setOption(
{
animation: false,
backgroundColor: 'transparent',
grid: { left: 48, right: 48, top: 36, bottom: 28 },
legend: { top: 0, textStyle: { color: muted } },
xAxis: {
type: 'time',
axisLine: { lineStyle: { color: border } },
axisLabel: { color: muted },
splitLine: { show: false }
},
yAxis: [axis('kg'), { ...axis('cm'), splitLine: { show: false } }],
tooltip: {
trigger: 'axis',
backgroundColor: style.getPropertyValue('--site-surface').trim(),
borderColor: border,
textStyle: { color: foreground }
},
series: series.map((s, i) => ({
...s,
type: 'line',
showSymbol: s.data.length < 60,
symbolSize: 5,
itemStyle: { color: `hsl(${i ? 200 : 25} 70% ${dark ? '65%' : '40%'})` },
lineStyle: { width: 2 }
}))
},
true
);
}
onMount(() => {
let disposed = false;
let resize: ResizeObserver | undefined;
let theme: MutationObserver | undefined;
import('echarts/core').then(async (echarts) => {
const [
{ LineChart },
{ GridComponent, TooltipComponent, LegendComponent },
{ CanvasRenderer }
] = await Promise.all([
import('echarts/charts'),
import('echarts/components'),
import('echarts/renderers')
]);
if (disposed) return;
echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
chart = echarts.init(element, undefined, { renderer: 'canvas' });
ready = true;
update();
resize = new ResizeObserver(() => chart?.resize());
resize.observe(element);
theme = new MutationObserver(update);
theme.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
});
});
return () => {
disposed = true;
resize?.disconnect();
theme?.disconnect();
chart?.dispose();
};
});
$effect(() => {
if (ready) update();
});
</script>
<div class="chart" bind:this={element} role="img" aria-label="Weight and waist trend"></div>
<style>
.chart {
width: 100%;
height: 240px;
}
</style>

129
src/lib/health/tables.ts Normal file
View File

@@ -0,0 +1,129 @@
// Client-safe description of the health tables: used by the forms on /trackers/health
// and by the server-side validator in #lib/server/health-import.ts. Keys are the
// snake_case column names, which are also the import (JSON/CSV) field names.
export type FieldType =
'date' | 'text' | 'textarea' | 'integer' | 'number' | 'datetime' | 'select' | 'uuid';
export type Field = {
key: string;
label: string;
type: FieldType;
required?: boolean;
options?: readonly string[];
min?: number;
max?: number;
step?: number;
maxLength?: number;
placeholder?: string;
};
export const medicationKinds = ['medication', 'supplement'] as const;
export const appointmentTypes = [
'appointment',
'checkup',
'condition',
'test',
'procedure',
'other'
] as const;
const notes: Field = { key: 'notes', label: 'Notes', type: 'textarea', maxLength: 4000 };
const date: Field = { key: 'date', label: 'Date', type: 'date', required: true };
export const healthTables = {
vaccinations: {
label: 'Vaccinations',
fields: [
{ key: 'vaccine', label: 'Vaccine', type: 'text', required: true, maxLength: 200 },
{ key: 'target', label: 'Disease / target', type: 'text', maxLength: 200 },
{ key: 'dose_no', label: 'Dose no.', type: 'integer', min: 1, max: 20 },
date,
{ key: 'batch_lot', label: 'Batch / lot', type: 'text', maxLength: 100 },
{ key: 'provider', label: 'Provider', type: 'text', maxLength: 200 },
{
key: 'country',
label: 'Country (ISO code)',
type: 'text',
maxLength: 2,
placeholder: 'NL'
},
notes
]
},
body_measurements: {
label: 'Body measurements',
fields: [
date,
{ key: 'weight_kg', label: 'Weight (kg)', type: 'number', min: 20, max: 400, step: 0.01 },
{
key: 'waist_cm',
label: 'Waist at navel (cm)',
type: 'number',
min: 30,
max: 250,
step: 0.1
},
{ key: 'body_fat_pct', label: 'Body fat (%)', type: 'number', min: 1, max: 75, step: 0.1 },
notes
]
},
workouts: {
label: 'Workouts',
fields: [
date,
{ key: 'exercise', label: 'Exercise', type: 'text', required: true, maxLength: 200 },
{ key: 'sets', label: 'Sets', type: 'integer', min: 1, max: 100 },
{ key: 'reps', label: 'Reps (per set)', type: 'integer', min: 0, max: 1000 },
{ key: 'load_kg', label: 'Load (kg)', type: 'number', min: 0, max: 1000, step: 0.25 },
{ key: 'rpe', label: 'RPE', type: 'number', min: 1, max: 10, step: 0.5 },
{ key: 'session_id', label: 'Session id (optional)', type: 'uuid' },
notes
]
},
nutrition_daily: {
label: 'Nutrition',
fields: [
date,
{ key: 'kcal', label: 'kcal', type: 'integer', min: 0, max: 20000 },
{ key: 'protein_g', label: 'Protein (g)', type: 'number', min: 0, max: 1000, step: 0.1 },
notes
]
},
sleep: {
label: 'Sleep',
fields: [
{ ...date, label: 'Date (wake-up day)' },
{ key: 'bedtime', label: 'Bedtime', type: 'datetime', required: true },
{ key: 'wake_time', label: 'Wake time', type: 'datetime', required: true },
notes
]
},
medications: {
label: 'Medications & supplements',
fields: [
{ key: 'name', label: 'Name', type: 'text', required: true, maxLength: 200 },
{ key: 'kind', label: 'Kind', type: 'select', options: medicationKinds },
{ key: 'dose', label: 'Dose', type: 'text', maxLength: 200, placeholder: '1 mg' },
{ key: 'frequency', label: 'Frequency', type: 'text', maxLength: 200, placeholder: 'daily' },
{ key: 'start_date', label: 'Start date', type: 'date' },
{ key: 'stop_date', label: 'Stop date', type: 'date' },
notes
]
},
appointments: {
label: 'Appointments & conditions',
fields: [
date,
{ key: 'type', label: 'Type', type: 'select', options: appointmentTypes },
{ key: 'provider', label: 'Provider', type: 'text', maxLength: 200 },
notes
]
}
} as const satisfies Record<string, { label: string; fields: readonly Field[] }>;
export type HealthTable = keyof typeof healthTables;
export const healthTableNames = Object.keys(healthTables) as HealthTable[];
export const isHealthTable = (value: unknown): value is HealthTable =>
typeof value === 'string' && Object.hasOwn(healthTables, value);

View File

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

View File

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

View File

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

View File

@@ -58,12 +58,24 @@ export const trackers = [
href: '/projects/cs-prices', href: '/projects/cs-prices',
external: false external: false
}, },
{
name: 'energy',
subtitle: 'Dutch electricity mix, utilization and gas storage',
href: '/projects/energy',
external: false
},
{ {
name: 'animaldex', name: 'animaldex',
subtitle: 'add neighborhood pets to the world map', subtitle: 'add neighborhood pets to the world map',
href: '/projects/animaldex', href: '/projects/animaldex',
external: false external: false
}, },
{
name: 'books',
subtitle: 'my library: what I am reading, have read and want to read',
href: '/trackers/books',
external: false
},
{ {
name: 'lists', name: 'lists',
subtitle: 'books, games, places and other things worth keeping', subtitle: 'books, games, places and other things worth keeping',
@@ -71,3 +83,19 @@ export const trackers = [
external: false external: false
} }
] as const; ] as const;
// Owner-only trackers: shown in the header, home page and /trackers only to the logged-in
// owner (layout data `isOwner`), never in the sitemap. Their routes 404 for everyone else.
export const privateTrackers = [
{
name: 'health',
subtitle: 'private: body, workouts, sleep, meds and vaccinations',
href: '/trackers/health',
external: false
}
] as const;
type Tracker = { name: string; subtitle: string; href: string; external: boolean };
export function visibleTrackers(isOwner: boolean): readonly Tracker[] {
return isOwner ? [...trackers, ...privateTrackers] : trackers;
}

View File

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

View File

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

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

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

View File

@@ -0,0 +1,77 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { parseRange, safeResolve, streamFile } from './files';
let root: string;
beforeAll(async () => {
root = await mkdtemp(path.join(tmpdir(), 'books-'));
await mkdir(path.join(root, 'library', 'a'), { recursive: true });
await writeFile(path.join(root, 'library', 'a', 'book.epub'), '0123456789');
await writeFile(path.join(root, 'secret.txt'), 'nope');
await symlink(path.join(root, 'secret.txt'), path.join(root, 'library', 'escape.epub'));
});
afterAll(() => rm(root, { recursive: true, force: true }));
describe('safeResolve', () => {
it('finds files inside the library', async () => {
expect(await safeResolve('library', 'a/book.epub', root)).toBe(
path.join(root, 'library', 'a', 'book.epub')
);
});
it.each([
'../secret.txt',
'a/../../secret.txt',
'/etc/passwd',
'escape.epub',
'a',
'',
'missing.pdf'
])('refuses %s', async (relative) => {
expect(await safeResolve('library', relative, root)).toBeNull();
});
});
describe('parseRange', () => {
it('parses single ranges', () => {
expect(parseRange('bytes=0-3', 10)).toEqual({ start: 0, end: 3 });
expect(parseRange('bytes=5-', 10)).toEqual({ start: 5, end: 9 });
expect(parseRange('bytes=-4', 10)).toEqual({ start: 6, end: 9 });
expect(parseRange('bytes=8-100', 10)).toEqual({ start: 8, end: 9 });
});
it('ignores or rejects bad ranges', () => {
expect(parseRange(null, 10)).toBeNull();
expect(parseRange('bytes=0-1,4-5', 10)).toBeNull();
expect(parseRange('bytes=10-', 10)).toBe('invalid');
expect(parseRange('bytes=5-2', 10)).toBe('invalid');
});
});
describe('streamFile', () => {
const file = () => path.join(root, 'library', 'a', 'book.epub');
it('serves the whole file', async () => {
const res = await streamFile(file(), new Request('http://x/'), {});
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/epub+zip');
expect(await res.text()).toBe('0123456789');
});
it('serves partial content', async () => {
const res = await streamFile(
file(),
new Request('http://x/', { headers: { range: 'bytes=2-4' } }),
{}
);
expect(res.status).toBe(206);
expect(res.headers.get('content-range')).toBe('bytes 2-4/10');
expect(await res.text()).toBe('234');
});
it('answers 416 for unsatisfiable ranges', async () => {
const res = await streamFile(
file(),
new Request('http://x/', { headers: { range: 'bytes=20-' } }),
{}
);
expect(res.status).toBe(416);
});
});

View File

@@ -0,0 +1,115 @@
// Book files and covers on disk. Files are owner-only and only ever streamed through an
// authenticated endpoint; nothing here is under static/.
//
// BOOKS_ROOT holds library/ (book files, books.files.path is relative to it) and covers/
// (books.works.cover_path is relative to it). In prod the container gets the host's books
// directory as a read-only bind mount at /books (see scripts/deploy-remote.sh).
import { createReadStream, existsSync } from 'node:fs';
import { realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
export function booksRoot(): string {
if (process.env.BOOKS_ROOT) return process.env.BOOKS_ROOT;
return existsSync('/books/library') ? '/books' : path.join(homedir(), 'books');
}
/**
* Resolves `relative` inside `<root>/<area>`, following symlinks, and refuses anything that
* escapes it (`..`, absolute paths, symlinks out). Returns null when missing or unsafe.
*/
export async function safeResolve(
area: 'library' | 'covers',
relative: string,
root = booksRoot()
): Promise<string | null> {
if (!relative || relative.includes('\0') || path.isAbsolute(relative)) return null;
const base = path.join(root, area);
try {
const realBase = await realpath(base);
const real = await realpath(path.join(base, relative));
if (real !== realBase && !real.startsWith(realBase + path.sep)) return null;
return (await stat(real)).isFile() ? real : null;
} catch {
return null;
}
}
const contentTypes: Record<string, string> = {
epub: 'application/epub+zip',
pdf: 'application/pdf',
mobi: 'application/x-mobipocket-ebook',
azw: 'application/vnd.amazon.ebook',
azw3: 'application/vnd.amazon.ebook',
fb2: 'application/x-fictionbook+xml',
cbz: 'application/vnd.comicbook+zip',
djvu: 'image/vnd.djvu',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
avif: 'image/avif'
};
export function contentType(file: string): string {
return contentTypes[path.extname(file).slice(1).toLowerCase()] ?? 'application/octet-stream';
}
/** Parses a single `bytes=a-b` range. Multi-range requests get the whole file. */
export function parseRange(
header: string | null,
size: number
): { start: number; end: number } | 'invalid' | null {
if (!header) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match || (!match[1] && !match[2])) return null;
let start: number;
let end: number;
if (!match[1]) {
const suffix = Number(match[2]);
if (suffix === 0) return 'invalid';
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = Number(match[1]);
end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
}
if (start > end || start >= size) return 'invalid';
return { start, end };
}
/** Streams a file with Range support (pdf.js fetches large PDFs in chunks). */
export async function streamFile(
file: string,
request: Request,
headers: Record<string, string>
): Promise<Response> {
const { size, mtime } = await stat(file);
const base = {
'content-type': contentType(file),
'accept-ranges': 'bytes',
'last-modified': mtime.toUTCString(),
...headers
};
const range = parseRange(request.headers.get('range'), size);
if (range === 'invalid')
return new Response(null, {
status: 416,
headers: { ...base, 'content-range': `bytes */${size}` }
});
const { start, end } = range ?? { start: 0, end: size - 1 };
const length = size === 0 ? 0 : end - start + 1;
const body =
request.method === 'HEAD' || size === 0
? null
: (Readable.toWeb(createReadStream(file, { start, end })) as unknown as ReadableStream);
return new Response(body, {
status: range ? 206 : 200,
headers: {
...base,
'content-length': String(length),
...(range ? { 'content-range': `bytes ${start}-${end}/${size}` } : {})
}
});
}

View File

@@ -0,0 +1,373 @@
// Book tracker queries. Public callers get catalogue fields only; owner-only data (files,
// notes, highlights, sessions) is loaded separately and only after requireOwner().
import { db } from '#lib/server/db/index.js';
import {
bookFiles,
bookNotes,
bookProgress,
bookSessions,
bookWorks
} from '#lib/server/db/schema/index.js';
import { and, asc, desc, eq, gte, sql } from 'drizzle-orm';
import {
formatPreference,
isReadable,
type BookCard,
type Highlight,
type LibraryStats,
type ReadingStatus
} from '#lib/books/shared.js';
import { pagesRead, SESSION_GAP_MS, type ProgressPayload } from '#lib/books/progress.js';
const TIME_ZONE = 'Europe/Amsterdam';
export const currentYear = () =>
Number(
new Intl.DateTimeFormat('en-CA', { timeZone: TIME_ZONE, year: 'numeric' }).format(new Date())
);
export function coverSrc(work: {
id: number;
coverPath: string | null;
coverUrl: string | null;
updatedAt?: Date | null;
}) {
if (work.coverPath)
return `/trackers/books/covers/${work.id}${work.updatedAt ? `?v=${work.updatedAt.getTime().toString(36)}` : ''}`;
if (work.coverUrl?.startsWith('https://')) return work.coverUrl;
return null;
}
const iso = (d: Date | null | undefined) => (d ? d.toISOString() : null);
export async function libraryCards(owner: boolean): Promise<BookCard[]> {
const formats = db
.select({
workId: bookFiles.workId,
formats: sql<string[]>`array_agg(distinct lower(${bookFiles.format}))`.as('formats')
})
.from(bookFiles)
.groupBy(bookFiles.workId)
.as('f');
const rows = await db
.select({
id: bookWorks.id,
title: bookWorks.title,
subtitle: bookWorks.subtitle,
authors: bookWorks.authors,
year: bookWorks.year,
tags: bookWorks.tags,
pageCount: bookWorks.pageCount,
coverPath: bookWorks.coverPath,
coverUrl: bookWorks.coverUrl,
createdAt: bookWorks.createdAt,
updatedAt: bookWorks.updatedAt,
status: bookProgress.status,
percent: bookProgress.percent,
rating: bookProgress.rating,
lastReadAt: bookProgress.lastReadAt,
finishedAt: bookProgress.finishedAt,
formats: formats.formats
})
.from(bookWorks)
.leftJoin(bookProgress, eq(bookProgress.workId, bookWorks.id))
.leftJoin(formats, eq(formats.workId, bookWorks.id))
.orderBy(asc(bookWorks.title));
return rows.map((r) => ({
id: r.id,
title: r.title,
subtitle: r.subtitle,
authors: r.authors,
year: r.year,
tags: r.tags,
pageCount: r.pageCount,
cover: coverSrc(r),
status: r.status ?? null,
percent: r.percent ?? 0,
rating: r.rating ?? null,
lastReadAt: iso(r.lastReadAt),
finishedAt: iso(r.finishedAt),
addedAt: r.createdAt.toISOString(),
formats: owner ? (r.formats ?? []) : [],
hasFile: owner ? (r.formats?.length ?? 0) > 0 : false
}));
}
export async function libraryStats(): Promise<LibraryStats> {
const year = currentYear();
const yearStart = sql`make_timestamptz(${year}, 1, 1, 0, 0, 0, ${TIME_ZONE})`;
const [[counts], [pages]] = await Promise.all([
db
.select({
total: sql<number>`(select count(*)::int from ${bookWorks})`,
finished: sql<number>`count(*) filter (where ${bookProgress.status} = 'finished' and ${bookProgress.finishedAt} >= ${yearStart})::int`,
reading: sql<number>`count(*) filter (where ${bookProgress.status} = 'reading')::int`
})
.from(bookProgress),
db
.select({ pages: sql<number>`coalesce(sum(${bookSessions.pagesRead}), 0)::int` })
.from(bookSessions)
.where(gte(bookSessions.startedAt, yearStart))
]);
return {
total: counts?.total ?? 0,
finishedThisYear: counts?.finished ?? 0,
pagesThisYear: pages?.pages ?? 0,
reading: counts?.reading ?? 0,
year
};
}
export async function getWork(id: number) {
const [work] = await db.select().from(bookWorks).where(eq(bookWorks.id, id)).limit(1);
if (!work) return null;
const [progress] = await db
.select()
.from(bookProgress)
.where(eq(bookProgress.workId, id))
.limit(1);
return { work, progress: progress ?? null };
}
export async function workFiles(workId: number) {
const files = await db
.select({
id: bookFiles.id,
path: bookFiles.path,
format: bookFiles.format,
sizeBytes: bookFiles.sizeBytes
})
.from(bookFiles)
.where(eq(bookFiles.workId, workId));
const rank = (f: string) => {
const i = formatPreference.indexOf(f.toLowerCase());
return i === -1 ? 99 : i;
};
return files
.map((f) => ({ ...f, format: f.format.toLowerCase(), readable: isReadable(f.format) }))
.sort((a, b) => rank(a.format) - rank(b.format));
}
export async function workNotes(workId: number): Promise<Highlight[]> {
const rows = await db
.select()
.from(bookNotes)
.where(eq(bookNotes.workId, workId))
.orderBy(desc(bookNotes.createdAt));
return rows.map((n) => ({
id: n.id,
fileId: n.fileId,
locator: n.locator,
page: n.page,
highlight: n.highlight,
note: n.note,
color: n.color,
createdAt: n.createdAt.toISOString()
}));
}
export async function workSessions(workId: number) {
const rows = await db
.select()
.from(bookSessions)
.where(eq(bookSessions.workId, workId))
.orderBy(desc(bookSessions.startedAt))
.limit(200);
return rows.map((s) => ({
id: s.id,
startedAt: s.startedAt.toISOString(),
endedAt: iso(s.endedAt),
startPercent: s.startPercent,
endPercent: s.endPercent,
startPage: s.startPage,
endPage: s.endPage,
pagesRead: s.pagesRead,
source: s.source,
note: s.note
}));
}
/** Sets the reading status, keeping the start/finish dates and percent consistent. */
export async function setStatus(workId: number, status: ReadingStatus) {
const now = new Date();
await db
.insert(bookProgress)
.values({
workId,
status,
startedAt: status === 'want_to_read' ? null : now,
finishedAt: status === 'finished' ? now : null,
percent: status === 'finished' ? 100 : 0
})
.onConflictDoUpdate({
target: bookProgress.workId,
set: {
status,
startedAt:
status === 'want_to_read'
? sql`${bookProgress.startedAt}`
: sql`coalesce(${bookProgress.startedAt}, ${now.toISOString()}::timestamptz)`,
finishedAt:
status === 'finished'
? sql`coalesce(${bookProgress.finishedAt}, ${now.toISOString()}::timestamptz)`
: null,
percent: status === 'finished' ? 100 : sql`${bookProgress.percent}`
}
});
}
export async function setRating(workId: number, rating: number | null) {
await db
.insert(bookProgress)
.values({ workId, rating })
.onConflictDoUpdate({ target: bookProgress.workId, set: { rating } });
}
/** Manual progress from the detail page (percent and/or page). */
export async function setManualProgress(workId: number, percent: number, page: number | null) {
await db
.insert(bookProgress)
.values({
workId,
percent,
page,
status: percent > 0 ? 'reading' : 'want_to_read',
startedAt: percent > 0 ? new Date() : null
})
.onConflictDoUpdate({
target: bookProgress.workId,
set: {
percent,
page,
status: sql`case when ${bookProgress.status} = 'want_to_read' and ${percent} > 0 then 'reading'::books.reading_status else ${bookProgress.status} end`,
startedAt: sql`coalesce(${bookProgress.startedAt}, case when ${percent} > 0 then now() end)`
}
});
}
/**
* Saves the reader position and keeps a reading session going. Returns the session id the
* reader should send back with its next save. A save more than SESSION_GAP_MS after the
* last one starts a new session.
*/
export async function saveProgress(workId: number, input: ProgressPayload) {
return db.transaction(async (tx) => {
const [work] = await tx
.select({ pageCount: bookWorks.pageCount })
.from(bookWorks)
.where(eq(bookWorks.id, workId))
.limit(1);
if (!work) return null;
if (input.fileId) {
const [file] = await tx
.select({ id: bookFiles.id })
.from(bookFiles)
.where(and(eq(bookFiles.id, input.fileId), eq(bookFiles.workId, workId)))
.limit(1);
if (!file) return null;
}
const [before] = await tx
.select()
.from(bookProgress)
.where(eq(bookProgress.workId, workId))
.limit(1);
const now = new Date();
await tx
.insert(bookProgress)
.values({
workId,
fileId: input.fileId ?? null,
locator: input.locator ?? null,
page: input.page ?? null,
percent: input.percent,
status: 'reading',
startedAt: now,
lastReadAt: now
})
.onConflictDoUpdate({
target: bookProgress.workId,
set: {
fileId: input.fileId ?? null,
locator: input.locator ?? null,
page: input.page ?? null,
percent: input.percent,
lastReadAt: now,
status: sql`case when ${bookProgress.status} = 'want_to_read' then 'reading'::books.reading_status else ${bookProgress.status} end`,
startedAt: sql`coalesce(${bookProgress.startedAt}, now())`
}
});
const pageCount = input.pageCount ?? work.pageCount;
let session = input.sessionId
? (
await tx
.select()
.from(bookSessions)
.where(and(eq(bookSessions.id, input.sessionId), eq(bookSessions.workId, workId)))
.limit(1)
)[0]
: undefined;
const last = session?.endedAt ?? session?.startedAt;
if (session && last && now.getTime() - last.getTime() > SESSION_GAP_MS) session = undefined;
if (!session) {
[session] = await tx
.insert(bookSessions)
.values({
workId,
fileId: input.fileId ?? null,
startedAt: now,
endedAt: now,
startPercent: before?.percent ?? input.percent,
endPercent: input.percent,
startPage: before?.page ?? input.page ?? null,
endPage: input.page ?? null,
pagesRead: 0,
source: 'reader'
})
.returning();
} else {
const startPage = session.startPage ?? input.page ?? null;
await tx
.update(bookSessions)
.set({
endedAt: now,
endPercent: input.percent,
endPage: input.page ?? null,
startPage,
pagesRead: pagesRead({
startPage: input.page != null ? startPage : null,
endPage: input.page,
startPercent: session.startPercent,
endPercent: input.percent,
pageCount
})
})
.where(eq(bookSessions.id, session.id));
}
return { sessionId: session.id };
});
}
export async function logManualSession(
workId: number,
input: {
startedAt: Date;
endedAt: Date | null;
startPage: number | null;
endPage: number | null;
startPercent: number | null;
endPercent: number | null;
note: string | null;
}
) {
const [work] = await db
.select({ pageCount: bookWorks.pageCount })
.from(bookWorks)
.where(eq(bookWorks.id, workId))
.limit(1);
await db.insert(bookSessions).values({
workId,
...input,
pagesRead: pagesRead({ ...input, pageCount: work?.pageCount }),
source: 'manual'
});
}

View File

@@ -0,0 +1,84 @@
// 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 { and, eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { bookFiles, bookNotes, bookWorks } from '#lib/server/db/schema/index.js';
import { highlightColors, type Highlight } from '#lib/books/shared.js';
export const notePayload = z
.object({
fileId: z.number().int().positive().nullish(),
locator: z.string().max(4000).nullish(),
page: z.number().int().min(0).max(100000).nullish(),
highlight: z.string().trim().max(20000).nullish(),
note: z.string().trim().max(20000).nullish(),
color: z.enum(highlightColors).nullish()
})
.refine((n) => !!(n.highlight || n.note), { message: 'Empty note' });
export const noteUpdate = z.object({
note: z.string().trim().max(20000).nullable().optional(),
color: z.enum(highlightColors).nullable().optional()
});
const toHighlight = (n: typeof bookNotes.$inferSelect): Highlight => ({
id: n.id,
fileId: n.fileId,
locator: n.locator,
page: n.page,
highlight: n.highlight,
note: n.note,
color: n.color,
createdAt: n.createdAt.toISOString()
});
export async function createNote(workId: number, input: z.infer<typeof notePayload>) {
const [work] = await db
.select({ id: bookWorks.id })
.from(bookWorks)
.where(eq(bookWorks.id, workId))
.limit(1);
if (!work) return null;
if (input.fileId) {
const [file] = await db
.select({ id: bookFiles.id })
.from(bookFiles)
.where(and(eq(bookFiles.id, input.fileId), eq(bookFiles.workId, workId)))
.limit(1);
if (!file) return null;
}
const [row] = await db
.insert(bookNotes)
.values({
workId,
fileId: input.fileId ?? null,
locator: input.locator ?? null,
page: input.page ?? null,
highlight: input.highlight || null,
note: input.note || null,
color: input.highlight ? (input.color ?? 'yellow') : null
})
.returning();
return toHighlight(row);
}
export async function updateNote(workId: number, id: number, input: z.infer<typeof noteUpdate>) {
const set: Partial<typeof bookNotes.$inferInsert> = {};
if (input.note !== undefined) set.note = input.note || null;
if (input.color !== undefined) set.color = input.color;
if (!Object.keys(set).length) return null;
const [row] = await db
.update(bookNotes)
.set(set)
.where(and(eq(bookNotes.id, id), eq(bookNotes.workId, workId)))
.returning();
return row ? toHighlight(row) : null;
}
export async function deleteNote(workId: number, id: number) {
const rows = await db
.delete(bookNotes)
.where(and(eq(bookNotes.id, id), eq(bookNotes.workId, workId)))
.returning({ id: bookNotes.id });
return rows.length > 0;
}

View File

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

View File

@@ -0,0 +1,179 @@
import { check, date, index, integer, numeric, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { healthSchema } from './schemas';
import { timestamps, timestamptz } from './helpers';
// Private health log (owner only, see requireOwner). No user_id: there is exactly one
// owner, and the whole schema is readable by personal_app only (no personal_ro grant).
// Numerics use mode 'number' so the app gets plain numbers instead of strings.
const pk = () => uuid('id').primaryKey().defaultRandom();
const day = (name = 'date') => date(name, { mode: 'string' });
const sane = (column: string) => sql.raw(`"${column}" >= DATE '1900-01-01'`);
export const healthVaccinations = healthSchema.table(
'vaccinations',
{
id: pk(),
vaccine: text('vaccine').notNull(),
target: text('target'), // disease(s) it protects against, e.g. "DTP", "COVID-19"
doseNo: integer('dose_no'),
date: day().notNull(),
batchLot: text('batch_lot'),
provider: text('provider'), // GGD, GP, travel clinic...
country: text('country'), // ISO 3166-1 alpha-2, e.g. NL
notes: text('notes'),
...timestamps
},
(t) => [
index('vaccinations_date_idx').on(t.date),
check('vaccinations_vaccine_check', sql`length(btrim(${t.vaccine})) > 0`),
check('vaccinations_dose_no_check', sql`${t.doseNo} IS NULL OR ${t.doseNo} BETWEEN 1 AND 20`),
check('vaccinations_country_check', sql`${t.country} IS NULL OR ${t.country} ~ '^[A-Z]{2}$'`),
check('vaccinations_date_check', sane('date'))
]
);
export const healthBodyMeasurements = healthSchema.table(
'body_measurements',
{
id: pk(),
date: day().notNull(),
weightKg: numeric('weight_kg', { precision: 5, scale: 2, mode: 'number' }),
waistCm: numeric('waist_cm', { precision: 5, scale: 1, mode: 'number' }), // at the navel
bodyFatPct: numeric('body_fat_pct', { precision: 4, scale: 1, mode: 'number' }),
notes: text('notes'),
...timestamps
},
(t) => [
index('body_measurements_date_idx').on(t.date),
check(
'body_measurements_any_check',
sql`${t.weightKg} IS NOT NULL OR ${t.waistCm} IS NOT NULL OR ${t.bodyFatPct} IS NOT NULL`
),
check(
'body_measurements_weight_check',
sql`${t.weightKg} IS NULL OR ${t.weightKg} BETWEEN 20 AND 400`
),
check(
'body_measurements_waist_check',
sql`${t.waistCm} IS NULL OR ${t.waistCm} BETWEEN 30 AND 250`
),
check(
'body_measurements_fat_check',
sql`${t.bodyFatPct} IS NULL OR ${t.bodyFatPct} BETWEEN 1 AND 75`
),
check('body_measurements_date_check', sane('date'))
]
);
export const healthWorkouts = healthSchema.table(
'workouts',
{
id: pk(),
date: day().notNull(),
sessionId: uuid('session_id'), // optional: groups the exercises of one session
exercise: text('exercise').notNull(),
sets: integer('sets'),
reps: integer('reps'), // per set
loadKg: numeric('load_kg', { precision: 6, scale: 2, mode: 'number' }),
rpe: numeric('rpe', { precision: 3, scale: 1, mode: 'number' }),
notes: text('notes'),
...timestamps
},
(t) => [
index('workouts_date_idx').on(t.date),
index('workouts_session_idx').on(t.sessionId),
check('workouts_exercise_check', sql`length(btrim(${t.exercise})) > 0`),
check('workouts_sets_check', sql`${t.sets} IS NULL OR ${t.sets} BETWEEN 1 AND 100`),
check('workouts_reps_check', sql`${t.reps} IS NULL OR ${t.reps} BETWEEN 0 AND 1000`),
check('workouts_load_check', sql`${t.loadKg} IS NULL OR ${t.loadKg} BETWEEN 0 AND 1000`),
check('workouts_rpe_check', sql`${t.rpe} IS NULL OR ${t.rpe} BETWEEN 1 AND 10`),
check('workouts_date_check', sane('date'))
]
);
export const healthNutritionDaily = healthSchema.table(
'nutrition_daily',
{
id: pk(),
date: day().notNull().unique('nutrition_daily_date_key'),
kcal: integer('kcal'),
proteinG: numeric('protein_g', { precision: 5, scale: 1, mode: 'number' }),
notes: text('notes'),
...timestamps
},
(t) => [
check('nutrition_daily_kcal_check', sql`${t.kcal} IS NULL OR ${t.kcal} BETWEEN 0 AND 20000`),
check(
'nutrition_daily_protein_check',
sql`${t.proteinG} IS NULL OR ${t.proteinG} BETWEEN 0 AND 1000`
),
check('nutrition_daily_date_check', sane('date'))
]
);
export const healthSleep = healthSchema.table(
'sleep',
{
id: pk(),
date: day().notNull().unique('sleep_date_key'), // the wake-up date of the night
bedtime: timestamptz('bedtime').notNull(),
wakeTime: timestamp('wake_time', { withTimezone: true }).notNull(),
hours: numeric('hours', { precision: 4, scale: 2, mode: 'number' }).generatedAlwaysAs(
sql`round((extract(epoch from (wake_time - bedtime)) / 3600)::numeric, 2)`
),
notes: text('notes'),
...timestamps
},
(t) => [
check('sleep_order_check', sql`${t.wakeTime} > ${t.bedtime}`),
check('sleep_length_check', sql`${t.wakeTime} - ${t.bedtime} <= interval '24 hours'`),
check('sleep_date_check', sane('date'))
]
);
export const healthMedications = healthSchema.table(
'medications',
{
id: pk(),
name: text('name').notNull(),
kind: text('kind').notNull().default('medication'),
dose: text('dose'), // free text: "1 mg", "5% solution, 1 ml"
frequency: text('frequency'), // free text: "daily", "2x daily", "as needed"
startDate: day('start_date'),
stopDate: day('stop_date'), // null = still taking it
notes: text('notes'),
...timestamps
},
(t) => [
index('medications_start_date_idx').on(t.startDate),
index('medications_stop_date_idx').on(t.stopDate),
check('medications_name_check', sql`length(btrim(${t.name})) > 0`),
check('medications_kind_check', sql`${t.kind} IN ('medication','supplement')`),
check(
'medications_dates_check',
sql`${t.stopDate} IS NULL OR ${t.startDate} IS NULL OR ${t.stopDate} >= ${t.startDate}`
)
]
);
export const healthAppointments = healthSchema.table(
'appointments',
{
id: pk(),
date: day().notNull(),
type: text('type').notNull().default('appointment'),
provider: text('provider'),
notes: text('notes'),
...timestamps
},
(t) => [
index('appointments_date_idx').on(t.date),
check(
'appointments_type_check',
sql`${t.type} IN ('appointment','checkup','condition','test','procedure','other')`
),
check('appointments_date_check', sane('date'))
]
);

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { amsterdamTime, cleanRow, parseCsv, toPayload, validatePayload } from './health-import';
describe('health import', () => {
it('parses CSV with quotes and semicolons', () => {
expect(parseCsv('date;kcal;notes\r\n2026-01-02;2500;"a; ""b"""\n')).toEqual([
{ date: '2026-01-02', kcal: '2500', notes: 'a; "b"' }
]);
});
it('validates and camel-cases rows', () => {
const r = cleanRow('body_measurements', {
date: '2026-01-02',
weight_kg: '80,5',
waist_cm: ''
});
expect(r).toEqual({
ok: true,
row: {
id: undefined,
values: { date: '2026-01-02', weightKg: 80.5, waistCm: null, bodyFatPct: null, notes: null }
}
});
expect(cleanRow('body_measurements', { date: '2026-01-02' }).ok).toBe(false);
expect(cleanRow('workouts', { date: '2026-01-02', exercise: 'squat', rpe: 11 }).ok).toBe(false);
expect(cleanRow('workouts', { date: '2026-01-02', exercise: 'squat', bogus: 1 }).ok).toBe(
false
);
expect(
cleanRow('vaccinations', { vaccine: 'X', date: '2026-01-02', country: 'nl' })
).toMatchObject({ ok: true, row: { values: { country: 'NL' } } });
});
it('resolves bare sleep times in Amsterdam time', () => {
const r = cleanRow('sleep', { date: '2026-07-02', bedtime: '23:30', wake_time: '07:15' });
expect(r.ok && (r.row.values.bedtime as Date).toISOString()).toBe('2026-07-01T21:30:00.000Z');
expect(r.ok && (r.row.values.wakeTime as Date).toISOString()).toBe('2026-07-02T05:15:00.000Z');
expect(amsterdamTime('2026-01-15', '12:00')?.toISOString()).toBe('2026-01-15T11:00:00.000Z');
const after = cleanRow('sleep', { date: '2026-07-02', bedtime: '00:40', wake_time: '08:00' });
expect(after.ok && (after.row.values.bedtime as Date).toISOString()).toBe(
'2026-07-01T22:40:00.000Z'
);
});
it('reports errors per row and table', () => {
const { errors } = validatePayload(
toPayload({ nutrition_daily: [{ date: 'x' }, { date: '2026-01-01', kcal: 2000 }] })
);
expect(errors).toEqual([
{ table: 'nutrition_daily', row: 1, error: expect.stringContaining('date') }
]);
expect(() => toPayload([{}])).toThrow();
expect(() => toPayload({ nope: [] })).toThrow();
});
});

View File

@@ -0,0 +1,349 @@
// Validation + import for the private health tables. Shared by the owner-only import
// endpoint (/trackers/health/import), the page's add/edit forms and
// scripts/health-import.ts, so it must not import anything from SvelteKit.
import { sql } from 'drizzle-orm';
import type { PgAsyncDatabase } from 'drizzle-orm/pg-core';
import {
healthAppointments,
healthBodyMeasurements,
healthMedications,
healthNutritionDaily,
healthSleep,
healthVaccinations,
healthWorkouts
} from './db/schema/health';
import { healthTables, isHealthTable, type Field, type HealthTable } from '../health/tables';
export const drizzleTables = {
vaccinations: healthVaccinations,
body_measurements: healthBodyMeasurements,
workouts: healthWorkouts,
nutrition_daily: healthNutritionDaily,
sleep: healthSleep,
medications: healthMedications,
appointments: healthAppointments
} as const;
/** Tables with one row per day: importing the same date again updates that day. */
const uniqueByDate = new Set<HealthTable>(['nutrition_daily', 'sleep']);
/** Columns that exports may contain but imports ignore. */
const ignored = new Set(['hours', 'created_at', 'updated_at']);
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const TIME_ZONE = 'Europe/Amsterdam';
export const MAX_ROWS = 5000;
type Row = Record<string, unknown>;
export type CleanRow = { id?: string; values: Record<string, unknown> };
export type RowResult = { ok: true; row: CleanRow } | { ok: false; error: string };
const camel = (key: string) => key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
export function validDate(raw: string): string | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null;
const d = new Date(`${raw}T12:00:00Z`);
return !Number.isNaN(d.valueOf()) && d.toISOString().slice(0, 10) === raw && raw >= '1900-01-01'
? raw
: null;
}
/** Offset of Europe/Amsterdam (ms) at a given UTC instant. */
function zoneOffset(utc: number): number {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: TIME_ZONE,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).formatToParts(new Date(utc));
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value);
return (
Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second')) -
utc
);
}
/** Wall-clock time in Amsterdam ("YYYY-MM-DD", "HH:MM[:SS]") to a Date. */
export function amsterdamTime(day: string, time: string): Date | null {
const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(time);
if (!m || !validDate(day)) return null;
const [h, min, s] = [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)];
if (h > 23 || min > 59 || s > 59) return null;
const [y, mo, d] = day.split('-').map(Number);
const wall = Date.UTC(y, mo - 1, d, h, min, s);
let utc = wall - zoneOffset(wall);
utc = wall - zoneOffset(utc); // second pass settles DST edges
return new Date(utc);
}
/**
* Datetimes: ISO with offset ("2026-10-24T07:10:00+02:00", "...Z"), local Amsterdam
* ("2026-10-24T07:10" / "2026-10-24 07:10", which is what <input type=datetime-local> sends),
* or a bare "HH:MM" resolved against the row's date by the caller.
*/
function parseDateTime(raw: string): Date | 'time' | null {
if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(raw)) return 'time';
const local = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}(?::\d{2})?)(?:\.\d+)?$/.exec(raw);
if (local) return amsterdamTime(local[1], local[2]);
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i.test(raw)) {
const d = new Date(raw);
return Number.isNaN(d.valueOf()) ? null : d;
}
return null;
}
function cleanField(field: Field, value: unknown): { value: unknown } | { error: string } {
if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
return field.required ? { error: `${field.key} is required` } : { value: null };
}
const raw = typeof value === 'string' ? value.trim() : value;
switch (field.type) {
case 'text':
case 'textarea': {
if (typeof raw !== 'string' && typeof raw !== 'number')
return { error: `${field.key} must be text` };
const text = String(raw);
if (field.maxLength && text.length > field.maxLength)
return { error: `${field.key} is longer than ${field.maxLength} characters` };
return { value: text };
}
case 'integer':
case 'number': {
const n = typeof raw === 'number' ? raw : Number(String(raw).replace(',', '.'));
if (!Number.isFinite(n)) return { error: `${field.key} must be a number` };
if (field.type === 'integer' && !Number.isInteger(n))
return { error: `${field.key} must be a whole number` };
if ((field.min !== undefined && n < field.min) || (field.max !== undefined && n > field.max))
return { error: `${field.key} must be between ${field.min} and ${field.max}` };
return { value: n };
}
case 'date': {
const d = typeof raw === 'string' ? validDate(raw) : null;
return d
? { value: d }
: { error: `${field.key} must be a date (YYYY-MM-DD, 1900 or later)` };
}
case 'datetime': {
const d = typeof raw === 'string' ? parseDateTime(raw) : null;
return d ? { value: d } : { error: `${field.key} must be an ISO datetime or HH:MM` };
}
case 'select':
return field.options?.includes(String(raw))
? { value: String(raw) }
: { error: `${field.key} must be one of ${field.options?.join(', ')}` };
case 'uuid':
return typeof raw === 'string' && UUID.test(raw)
? { value: raw.toLowerCase() }
: { error: `${field.key} must be a UUID` };
}
}
/** Validate one row (snake_case keys) for a table. Unknown keys are rejected. */
export function cleanRow(table: HealthTable, input: Row): RowResult {
if (!input || typeof input !== 'object' || Array.isArray(input))
return { ok: false, error: 'row must be an object' };
const fields: readonly Field[] = healthTables[table].fields;
const known = new Set(fields.map((f) => f.key));
for (const key of Object.keys(input))
if (key !== 'id' && !known.has(key) && !ignored.has(key))
return { ok: false, error: `unknown column "${key}"` };
let id: string | undefined;
if (input.id !== undefined && input.id !== null && input.id !== '') {
if (typeof input.id !== 'string' || !UUID.test(input.id))
return { ok: false, error: 'id must be a UUID' };
id = input.id.toLowerCase();
}
const v: Record<string, unknown> = {};
for (const field of fields) {
const result = cleanField(field, input[field.key]);
if ('error' in result) return { ok: false, error: result.error };
v[field.key] = result.value;
}
// Table-specific rules (mirrors the CHECK constraints, with friendlier messages).
if (table === 'vaccinations' && typeof v.country === 'string') {
const country = v.country.toUpperCase();
v.country = country;
if (!/^[A-Z]{2}$/.test(country))
return { ok: false, error: 'country must be a 2-letter ISO code, e.g. NL' };
}
if (
table === 'body_measurements' &&
v.weight_kg === null &&
v.waist_cm === null &&
v.body_fat_pct === null
)
return { ok: false, error: 'give at least one of weight_kg, waist_cm, body_fat_pct' };
if (table === 'medications') {
v.kind ??= 'medication';
if (v.start_date && v.stop_date && String(v.stop_date) < String(v.start_date))
return { ok: false, error: 'stop_date is before start_date' };
}
if (table === 'appointments') v.type ??= 'appointment';
if (table === 'sleep') {
const day = String(v.date);
const wakeRaw = String(input.wake_time).trim();
const bedRaw = String(input.bedtime).trim();
if (v.wake_time === 'time') v.wake_time = amsterdamTime(day, wakeRaw);
if (v.bedtime === 'time') {
// A bare bedtime is on the wake-up day if it is before the wake time, else the evening before.
const sameDay = amsterdamTime(day, bedRaw);
const wake = v.wake_time as Date | null;
if (sameDay && wake && sameDay < wake) v.bedtime = sameDay;
else {
const prev = new Date(`${day}T12:00:00Z`);
prev.setUTCDate(prev.getUTCDate() - 1);
v.bedtime = amsterdamTime(prev.toISOString().slice(0, 10), bedRaw);
}
}
const bed = v.bedtime as Date | null;
const wake = v.wake_time as Date | null;
if (!bed || !wake) return { ok: false, error: 'invalid bedtime / wake_time' };
if (wake <= bed) return { ok: false, error: 'wake_time must be after bedtime' };
if (wake.getTime() - bed.getTime() > 24 * 3600e3)
return { ok: false, error: 'sleep longer than 24 hours' };
} else {
for (const key of Object.keys(v))
if (v[key] === 'time') return { ok: false, error: `${key} needs a full datetime` };
}
const values: Record<string, unknown> = {};
for (const [key, value] of Object.entries(v)) values[camel(key)] = value;
return { ok: true, row: { id, values } };
}
/** Minimal RFC 4180 CSV parser: header row, quoted fields, CRLF, BOM. Semicolons allowed as delimiter. */
export function parseCsv(text: string): Row[] {
const src = text.replace(/^\uFEFF/, '');
const firstLine = src.split(/\r?\n/, 1)[0] ?? '';
const delimiter =
(firstLine.match(/;/g)?.length ?? 0) > (firstLine.match(/,/g)?.length ?? 0) ? ';' : ',';
const records: string[][] = [];
let record: string[] = [];
let field = '';
let quoted = false;
const endField = () => {
record.push(field);
field = '';
};
const endRecord = () => {
endField();
records.push(record);
record = [];
};
for (let i = 0; i < src.length; i++) {
const c = src[i];
if (quoted) {
if (c === '"' && src[i + 1] === '"') {
field += '"';
i++;
} else if (c === '"') quoted = false;
else field += c;
} else if (c === '"' && field === '') quoted = true;
else if (c === delimiter) endField();
else if (c === '\n' || c === '\r') {
if (c === '\r' && src[i + 1] === '\n') i++;
endRecord();
} else field += c;
}
if (quoted) throw new Error('CSV has an unterminated quoted field');
if (field !== '' || record.length) endRecord();
const rows = records.filter((r) => r.some((cell) => cell.trim() !== ''));
if (!rows.length) return [];
const header = rows[0].map((h) => h.trim().toLowerCase());
if (new Set(header).size !== header.length) throw new Error('CSV header has duplicate columns');
return rows.slice(1).map((cells, line) => {
if (cells.length > header.length)
throw new Error(`CSV row ${line + 2} has more cells than the header`);
return Object.fromEntries(header.map((h, i) => [h, cells[i] ?? '']));
});
}
export type Payload = Partial<Record<HealthTable, Row[]>>;
/**
* Accepted shapes:
* JSON object keyed by table: { "vaccinations": [ {...}, ... ], "sleep": [ ... ] }
* JSON array of rows + table: [ {...}, ... ] (table given separately)
* CSV with a header row + table
*/
export function toPayload(body: unknown, table?: string | null): Payload {
if (Array.isArray(body)) {
if (!isHealthTable(table))
throw new Error('a plain list of rows needs a table (?table= or --table)');
return { [table]: body };
}
if (!body || typeof body !== 'object')
throw new Error('expected a JSON object keyed by table, or an array of rows');
const payload: Payload = {};
for (const [key, rows] of Object.entries(body)) {
if (!isHealthTable(key)) throw new Error(`unknown table "${key}"`);
if (!Array.isArray(rows)) throw new Error(`"${key}" must be an array of rows`);
payload[key] = rows as Row[];
}
return payload;
}
export type ValidationError = { table: HealthTable; row: number; error: string };
export function validatePayload(payload: Payload) {
const clean: Partial<Record<HealthTable, CleanRow[]>> = {};
const errors: ValidationError[] = [];
let total = 0;
for (const [table, rows] of Object.entries(payload) as [HealthTable, Row[]][]) {
total += rows.length;
clean[table] = [];
rows.forEach((row, i) => {
const result = cleanRow(table, row);
if (result.ok) clean[table]!.push(result.row);
else errors.push({ table, row: i + 1, error: result.error });
});
}
if (total > MAX_ROWS)
errors.push({
table: Object.keys(payload)[0] as HealthTable,
row: 0,
error: `more than ${MAX_ROWS} rows`
});
return { clean, errors };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Db = PgAsyncDatabase<any, any>;
export type ImportCounts = Partial<Record<HealthTable, { inserted: number; updated: number }>>;
/** Insert/update rows; all-or-nothing in one transaction. Rows with an id update that row;
* nutrition_daily and sleep upsert on date. */
export async function writeRows(
db: Db,
clean: Partial<Record<HealthTable, CleanRow[]>>
): Promise<ImportCounts> {
return db.transaction(async (tx) => {
const counts: ImportCounts = {};
for (const [table, rows] of Object.entries(clean) as [HealthTable, CleanRow[]][]) {
const target = drizzleTables[table];
const count = { inserted: 0, updated: 0 };
for (const { id, values } of rows) {
const data = { ...values, ...(id ? { id } : {}) };
const set = { ...values, updatedAt: new Date() };
const conflictTarget = id
? target.id
: uniqueByDate.has(table)
? (target as typeof healthSleep).date
: null;
const query = tx.insert(target).values(data as never);
const [result] = await (
conflictTarget
? query.onConflictDoUpdate({ target: conflictTarget, set: set as never })
: query
).returning({ inserted: sql<boolean>`(xmax = 0)` });
if (result?.inserted) count.inserted++;
else count.updated++;
}
counts[table] = count;
}
return counts;
});
}

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from './owner';
const event = (user: Partial<NonNullable<App.Locals['user']>> | null) =>
({ locals: { user, session: null } }) as unknown as { locals: App.Locals };
describe('owner gate', () => {
it('is only the owner account', () => {
expect(isOwner(event(null))).toBe(false);
expect(isOwner(event({ role: 'admin', email: 'someone@example.com' }))).toBe(false);
expect(isOwner(event({ role: 'user', email: 'Stan@StanRunge.dev' }))).toBe(true);
});
it('matches owner-only paths', () => {
expect(isOwnerOnlyPath('/trackers/health')).toBe(true);
expect(isOwnerOnlyPath('/trackers/health/')).toBe(true);
expect(isOwnerOnlyPath('/trackers/Health/import')).toBe(true);
expect(isOwnerOnlyPath('/trackers/healthy')).toBe(false);
expect(isOwnerOnlyPath('/trackers')).toBe(false);
});
it('gates the book reader, files and APIs but not the catalogue', () => {
expect(isOwnerOnlyPath('/trackers/books/files/12')).toBe(true);
expect(isOwnerOnlyPath('/trackers/books/12/read')).toBe(true);
expect(isOwnerOnlyPath('/trackers/books/12/progress')).toBe(true);
expect(isOwnerOnlyPath('/trackers/books/12/notes/3')).toBe(true);
expect(isOwnerOnlyPath('/trackers/books')).toBe(false);
expect(isOwnerOnlyPath('/trackers/books/12')).toBe(false);
expect(isOwnerOnlyPath('/trackers/books/covers/12')).toBe(false);
expect(isOwnerOnlyPath('/trackers/books/12/reader-notes-public')).toBe(false);
});
it('only lets the owner write in owner areas', () => {
expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
expect(isOwnerWritePath('/trackers/books')).toBe(true);
expect(isOwnerWritePath('/trackers/health/import')).toBe(true);
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
expect(isOwnerWritePath('/lists/books')).toBe(false);
});
});

50
src/lib/server/owner.ts Normal file
View File

@@ -0,0 +1,50 @@
import { error } from '@sveltejs/kit';
// The site has exactly one owner: Stan's account (prod auth.user id tfn6OZDOpiQvMARIH65HQ2XJuaVvmGEe).
// Owner-only data, such as the health tracker, is gated on that account's email, not on
// a role, so other users (now or later, e.g. on a public tracking project) never see it.
// Override with OWNER_EMAIL if the address ever changes.
const OWNER_EMAIL = (process.env.OWNER_EMAIL || 'stan@stanrunge.dev').trim().toLowerCase();
type OwnerEvent = { locals: App.Locals };
export function isOwner(event: OwnerEvent): boolean {
return event.locals.user?.email?.trim().toLowerCase() === OWNER_EMAIL;
}
/** Owner-only routes answer 404 to everyone else, so they don't reveal that they exist. */
export function requireOwner(event: OwnerEvent) {
if (!isOwner(event)) error(404, 'Not Found');
return event.locals.user!;
}
/** Paths that only the owner may see (checked in hooks.server.ts before any route runs). */
export const ownerOnlyPrefixes = ['/trackers/health', '/trackers/books/files'] as const;
/**
* Owner-only paths inside otherwise public areas: the book reader and its progress/notes
* APIs. /trackers/books and /trackers/books/<id> themselves are public catalogue pages.
*/
export const ownerOnlyPatterns = [/^\/trackers\/books\/\d+\/(read|progress|notes)(\/|$)/] as const;
/**
* 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;
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
const under = (path: string, prefix: string) => path === prefix || path.startsWith(`${prefix}/`);
export function isOwnerOnlyPath(pathname: string): boolean {
const path = normalise(pathname);
return (
ownerOnlyPrefixes.some((prefix) => under(path, prefix)) ||
ownerOnlyPatterns.some((pattern) => pattern.test(path))
);
}
export function isOwnerWritePath(pathname: string): boolean {
const path = normalise(pathname);
return ownerWritePrefixes.some((prefix) => under(path, prefix));
}

View File

@@ -18,6 +18,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/': 'Home', '/': 'Home',
'/projects': 'Projects', '/projects': 'Projects',
'/trackers': 'Trackers', '/trackers': 'Trackers',
'/trackers/books': 'Books',
'/blog': 'Blog', '/blog': 'Blog',
'/lists': 'Lists', '/lists': 'Lists',
'/login': 'Sign in', '/login': 'Sign in',
@@ -30,6 +31,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/projects/animaldex/admin': 'Animaldex admin', '/projects/animaldex/admin': 'Animaldex admin',
'/projects/derby': 'Derby Lens', '/projects/derby': 'Derby Lens',
'/projects/cs-prices': 'CS Prices', '/projects/cs-prices': 'CS Prices',
'/projects/energy': 'Energy',
'/projects/game-tracker': 'Game Tracker', '/projects/game-tracker': 'Game Tracker',
'/projects/language-learner': 'Language Learner', '/projects/language-learner': 'Language Learner',
'/projects/money-tracker': 'Money Tracker', '/projects/money-tracker': 'Money Tracker',

View File

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

View File

@@ -7,7 +7,7 @@
import { getSeo } from '#lib/seo.js'; import { getSeo } from '#lib/seo.js';
import CopyButton from '#lib/components/CopyButton.svelte'; import CopyButton from '#lib/components/CopyButton.svelte';
import ProjectIcon from '#lib/components/ProjectIcon.svelte'; import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import { projects, trackers } from '#lib/projects.js'; import { projects, visibleTrackers } from '#lib/projects.js';
import { Button, buttonVariants } from '#lib/components/ui/button/index.js'; import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
import * as Sheet from '#lib/components/ui/sheet/index.js'; import * as Sheet from '#lib/components/ui/sheet/index.js';
import ArrowRight from 'lucide-svelte/icons/arrow-right'; import ArrowRight from 'lucide-svelte/icons/arrow-right';
@@ -30,6 +30,8 @@
let { data, children } = $props(); let { data, children } = $props();
const seo = $derived(getSeo(page.url.pathname, page.data, data.environment)); const seo = $derived(getSeo(page.url.pathname, page.data, data.environment));
// The book reader is a full-screen view; the site header and footer would sit on top of it.
const immersive = $derived(/^\/trackers\/books\/\d+\/read\/?$/.test(page.url.pathname));
let menuOpen = $state(false); let menuOpen = $state(false);
// Which header dropdown is open (desktop): '/projects', '/trackers' or none. // Which header dropdown is open (desktop): '/projects', '/trackers' or none.
let openMenu = $state<string | null>(null); let openMenu = $state<string | null>(null);
@@ -42,13 +44,11 @@
{ label: 'blog', href: '/blog' }, { label: 'blog', href: '/blog' },
{ label: 'lists', href: '/lists' } { label: 'lists', href: '/lists' }
] as const; ] as const;
const menus: Record< type MenuItem = { name: string; subtitle: string; href: string; external: boolean };
string, const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
{ label: string; items: readonly (typeof projects | typeof trackers)[number][] }
> = {
'/projects': { label: 'projects', items: projects }, '/projects': { label: 'projects', items: projects },
'/trackers': { label: 'trackers', items: trackers } '/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) }
}; });
const socialLinks = [ const socialLinks = [
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' }, { icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
{ icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' }, { icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' },
@@ -186,6 +186,7 @@
Fix: lift the header (relative z-50) and isolate <main> so page z-indexes stay inside it. Fix: lift the header (relative z-50) and isolate <main> so page z-indexes stay inside it.
Dialogs/sheets/popovers are portalled to <body> and still render above both. Dialogs/sheets/popovers are portalled to <body> and still render above both.
--> -->
{#if !immersive}
<header class="relative z-50 border-b border-border bg-background/95 backdrop-blur"> <header class="relative z-50 border-b border-border bg-background/95 backdrop-blur">
<div class="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3 sm:px-6"> <div class="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3 sm:px-6">
<a <a
@@ -258,7 +259,10 @@
>{project.subtitle}</span >{project.subtitle}</span
></span ></span
> >
{#if project.external}<ExternalLink size={14} class="ml-auto shrink-0" />{/if} {#if project.external}<ExternalLink
size={14}
class="ml-auto shrink-0"
/>{/if}
</a> </a>
<!-- eslint-enable svelte/no-navigation-without-resolve --> <!-- eslint-enable svelte/no-navigation-without-resolve -->
{/each} {/each}
@@ -339,8 +343,10 @@
aria-label={`Theme: ${theme}. Switch mode`}>{@render themeIcon()}</Button aria-label={`Theme: ${theme}. Switch mode`}>{@render themeIcon()}</Button
> >
{#if data.user} {#if data.user}
<Button href={resolve('account')} variant="outline" onclick={() => (menuOpen = false)} <Button
>account</Button href={resolve('account')}
variant="outline"
onclick={() => (menuOpen = false)}>account</Button
> >
<form method="POST" action="/logout"> <form method="POST" action="/logout">
<Button type="submit" variant="outline">sign out</Button> <Button type="submit" variant="outline">sign out</Button>
@@ -360,7 +366,9 @@
</Sheet.Root> </Sheet.Root>
</div> </div>
</header> </header>
{/if}
<main id="main" class="isolate min-w-0 flex-1">{@render children()}</main> <main id="main" class="isolate min-w-0 flex-1">{@render children()}</main>
{#if !immersive}
<footer <footer
class="mt-16 border-t border-[var(--site-border)]" class="mt-16 border-t border-[var(--site-border)]"
class:tracker-footer={page.route.id === '/projects/game-tracker'} class:tracker-footer={page.route.id === '/projects/game-tracker'}
@@ -374,7 +382,7 @@
></span ></span
> >
<a <a
href="https://git.stanrunge.dev/stan/personal-website" href="https://git.stanrunge.dev/stan/website"
rel="external" rel="external"
class="inline-flex items-center gap-1 hover:text-[var(--site-text)]" class="inline-flex items-center gap-1 hover:text-[var(--site-text)]"
>source <ExternalLink size={14} aria-hidden="true" /></a >source <ExternalLink size={14} aria-hidden="true" /></a
@@ -395,6 +403,7 @@
</nav> </nav>
</div> </div>
</footer> </footer>
{/if}
</div> </div>
<style> <style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { listSummaries } from '#lib/server/lists.js'; import { listSummaries } from '#lib/server/lists.js';
import { db } from '#lib/server/db/index.js'; import { db } from '#lib/server/db/index.js';
import { blogArticles } from '#lib/server/db/schema/index.js'; import { blogArticles, bookWorks } from '#lib/server/db/schema/index.js';
import { SITE_ORIGIN } from '#lib/seo.js'; import { SITE_ORIGIN } from '#lib/seo.js';
import { races } from '#lib/derby/races.js'; import { races } from '#lib/derby/races.js';
import { COURSES } from '#lib/language-learner/courses/index.js'; import { COURSES } from '#lib/language-learner/courses/index.js';
@@ -13,11 +13,13 @@ const staticPaths = [
'/trackers', '/trackers',
'/blog', '/blog',
'/lists', '/lists',
'/trackers/books',
'/projects/game-tracker', '/projects/game-tracker',
'/projects/game-tracker/pokemon', '/projects/game-tracker/pokemon',
'/projects/game-tracker/osu', '/projects/game-tracker/osu',
'/projects/game-tracker/pokemon/locations', '/projects/game-tracker/pokemon/locations',
'/projects/animaldex', '/projects/animaldex',
'/projects/energy',
'/projects/derby', '/projects/derby',
'/projects/spotify-beatmap-finder', '/projects/spotify-beatmap-finder',
'/projects/world-opinions', '/projects/world-opinions',
@@ -39,12 +41,13 @@ function escapeXml(value: string): string {
} }
export const GET: RequestHandler = async () => { export const GET: RequestHandler = async () => {
const [articles, lists] = await Promise.all([ const [articles, lists, books] = await Promise.all([
db db
.select({ slug: blogArticles.slug, updatedAt: blogArticles.updatedAt }) .select({ slug: blogArticles.slug, updatedAt: blogArticles.updatedAt })
.from(blogArticles) .from(blogArticles)
.where(eq(blogArticles.status, 'published')), .where(eq(blogArticles.status, 'published')),
listSummaries() listSummaries(),
db.select({ id: bookWorks.id, updatedAt: bookWorks.updatedAt }).from(bookWorks)
]); ]);
const entries: { path: string; lastmod?: string }[] = [ const entries: { path: string; lastmod?: string }[] = [
...staticPaths.map((path) => ({ path })), ...staticPaths.map((path) => ({ path })),
@@ -53,7 +56,11 @@ export const GET: RequestHandler = async () => {
path: `/blog/${encodeURIComponent(article.slug)}`, path: `/blog/${encodeURIComponent(article.slug)}`,
lastmod: article.updatedAt.toISOString() lastmod: article.updatedAt.toISOString()
})), })),
...lists.map((list) => ({ path: `/lists/${encodeURIComponent(list.slug)}` })) ...lists.map((list) => ({ path: `/lists/${encodeURIComponent(list.slug)}` })),
...books.map((book) => ({
path: `/trackers/books/${book.id}`,
lastmod: book.updatedAt.toISOString()
}))
]; ];
const body = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries const body = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries
.map( .map(

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
import { isOwner } from '#lib/server/owner.js';
import { libraryCards, libraryStats } from '#lib/server/books/library.js';
import type { PageServerLoad } from './$types';
// Public catalogue: covers, titles, status, progress and rating. Files, formats and
// placeholders (no file yet) are only included for the owner.
export const load: PageServerLoad = async (event) => {
const owner = isOwner(event);
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
const [books, stats] = await Promise.all([libraryCards(owner), libraryStats()]);
return { books, stats, owner };
};

View File

@@ -0,0 +1,670 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import BookCover from '#lib/books/BookCover.svelte';
import Stars from '#lib/books/Stars.svelte';
import {
authorLine,
readingStatuses,
statusLabels,
type BookCard,
type ReadingStatus
} from '#lib/books/shared.js';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import Search from 'lucide-svelte/icons/search';
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
import ListIcon from 'lucide-svelte/icons/list';
import BookOpen from 'lucide-svelte/icons/book-open';
import FileQuestion from 'lucide-svelte/icons/file-question';
import X from 'lucide-svelte/icons/x';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
type Sort = 'recent' | 'title' | 'author' | 'progress' | 'rating' | 'added';
const sorts: Record<Sort, string> = {
recent: 'recently read',
title: 'title',
author: 'author',
progress: 'progress',
rating: 'rating',
added: 'recently added'
};
let query = $state('');
let status = $state<ReadingStatus | 'all' | 'none'>('all');
let subject = $state('');
let format = $state('');
let sort = $state<Sort>('recent');
let view = $state<'grid' | 'list'>('grid');
const numberFormat = new Intl.NumberFormat('en-GB');
const dateFormat = new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'Europe/Amsterdam'
});
const reading = $derived(
data.books
.filter((b) => b.status === 'reading')
.toSorted((a, b) => (b.lastReadAt ?? '').localeCompare(a.lastReadAt ?? ''))
);
const counts = $derived.by(() => {
const c: Record<string, number> = { all: data.books.length, none: 0 };
for (const s of readingStatuses) c[s] = 0;
for (const b of data.books) c[b.status ?? 'none']++;
return c;
});
const subjects = $derived.by(() => {
const tally: Record<string, number> = {};
for (const b of data.books) for (const t of b.tags) tally[t] = (tally[t] ?? 0) + 1;
return Object.entries(tally)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 80);
});
const formats = $derived([...new Set(data.books.flatMap((b) => b.formats))].sort());
const placeholders = $derived(data.owner ? data.books.filter((b) => !b.hasFile).length : 0);
const surname = (b: BookCard) => (b.authors[0] ?? '~').split(' ').at(-1)!.toLowerCase();
const compare: Record<Sort, (a: BookCard, b: BookCard) => number> = {
recent: (a, b) =>
(b.lastReadAt ?? b.finishedAt ?? '').localeCompare(a.lastReadAt ?? a.finishedAt ?? '') ||
a.title.localeCompare(b.title),
title: (a, b) => a.title.localeCompare(b.title),
author: (a, b) => surname(a).localeCompare(surname(b)) || a.title.localeCompare(b.title),
progress: (a, b) => b.percent - a.percent || a.title.localeCompare(b.title),
rating: (a, b) => (b.rating ?? 0) - (a.rating ?? 0) || a.title.localeCompare(b.title),
added: (a, b) => b.addedAt.localeCompare(a.addedAt)
};
const filtered = $derived.by(() => {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
return data.books
.filter((b) => {
if (status !== 'all' && (b.status ?? 'none') !== status) return false;
if (subject && !b.tags.includes(subject)) return false;
if (format === 'placeholder' ? b.hasFile : format && !b.formats.includes(format))
return false;
if (!words.length) return true;
const haystack = [b.title, b.subtitle, ...b.authors, ...b.tags, b.year]
.join(' ')
.toLowerCase();
return words.every((w) => haystack.includes(w));
})
.toSorted(compare[sort]);
});
const filtering = $derived(!!query || status !== 'all' || !!subject || !!format);
function clearFilters() {
query = '';
status = 'all';
subject = '';
format = '';
}
function setView(next: 'grid' | 'list') {
view = next;
try {
localStorage.setItem('books:view', next);
} catch {
// Not persisted; fine.
}
}
// Restore the last grid/list choice (client only).
onMount(() => {
try {
const saved = localStorage.getItem('books:view');
if (saved === 'list' || saved === 'grid') view = saved;
} catch {
// Ignore.
}
});
const pct = (n: number) => `${Math.round(n)}%`;
const bookHref = (b: BookCard) => resolve('/trackers/books/[id=integer]', { id: String(b.id) });
const readHref = (b: BookCard) =>
resolve('/trackers/books/[id=integer]/read', { id: String(b.id) });
</script>
{#snippet progressBar(b: BookCard, thin = false)}
{#if b.status === 'reading' || (b.percent > 0 && b.status !== 'finished')}
<span
class={['bar', thin && 'thin']}
role="progressbar"
aria-label="Progress"
aria-valuenow={Math.round(b.percent)}
aria-valuemin={0}
aria-valuemax={100}><span style:width={pct(b.percent)}></span></span
>
{/if}
{/snippet}
{#snippet statusChip(s: ReadingStatus | null)}
{#if s}<span class={['chip', `s-${s}`]}>{statusLabels[s]}</span>{/if}
{/snippet}
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<header class="mb-8">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">books</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
What I'm reading, what I've read and what's next.
</p>
</header>
<dl class="stats mb-10">
<div>
<dt>finished in {data.stats.year}</dt>
<dd>{numberFormat.format(data.stats.finishedThisYear)}</dd>
</div>
<div>
<dt>pages read in {data.stats.year}</dt>
<dd>{numberFormat.format(data.stats.pagesThisYear)}</dd>
</div>
<div>
<dt>currently reading</dt>
<dd>{data.stats.reading}</dd>
</div>
<div>
<dt>in the library</dt>
<dd>
{numberFormat.format(data.stats.total)}
{#if placeholders}<small title="Books without a file yet"
>{placeholders} placeholder{placeholders === 1 ? '' : 's'}</small
>{/if}
</dd>
</div>
</dl>
{#if reading.length && !filtering}
<section class="mb-12" aria-labelledby="now-reading">
<h2 id="now-reading" class="mb-4 text-lg font-semibold tracking-tight">now reading</h2>
<ul class="now">
{#each reading.slice(0, 6) as b (b.id)}
<li class="now-card">
<a href={bookHref(b)} class="now-cover" aria-label={b.title}>
<BookCover src={b.cover} title={b.title} authors={b.authors} size="md" />
</a>
<div class="min-w-0 flex-1">
<a href={bookHref(b)} class="line-clamp-2 leading-snug font-medium hover:underline"
>{b.title}</a
>
<p class="mt-0.5 truncate text-sm text-[var(--site-muted)]">
{authorLine(b.authors)}
</p>
<div class="mt-3 flex items-center gap-2 text-xs text-[var(--site-muted)]">
<span class="bar grow"><span style:width={pct(b.percent)}></span></span>
<span class="tabular-nums">{pct(b.percent)}</span>
</div>
{#if data.owner && b.hasFile}
<a href={readHref(b)} class="continue"><BookOpen size={14} /> continue</a>
{/if}
</div>
</li>
{/each}
</ul>
</section>
{/if}
<div class="toolbar">
<label class="search">
<Search size={16} aria-hidden="true" />
<Input
type="search"
placeholder="Search title, author or subject"
aria-label="Search books"
bind:value={query}
class="border-0 bg-transparent pl-0 shadow-none focus-visible:ring-0"
/>
</label>
<div class="flex flex-wrap items-center gap-2">
{#if subjects.length}
<NativeSelect aria-label="Subject" bind:value={subject} class="max-w-44">
<option value="">all subjects</option>
{#each subjects as [name, count] (name)}<option value={name}>{name} ({count})</option
>{/each}
</NativeSelect>
{/if}
{#if data.owner && (formats.length || placeholders)}
<NativeSelect aria-label="Format" bind:value={format}>
<option value="">all formats</option>
{#each formats as f (f)}<option value={f}>{f.toUpperCase()}</option>{/each}
{#if placeholders}<option value="placeholder">no file yet</option>{/if}
</NativeSelect>
{/if}
<NativeSelect aria-label="Sort by" bind:value={sort}>
{#each Object.entries(sorts) as [key, label] (key)}<option value={key}>{label}</option
>{/each}
</NativeSelect>
<div class="toggle" role="group" aria-label="View">
<button
type="button"
aria-pressed={view === 'grid'}
aria-label="Cover grid"
onclick={() => setView('grid')}><LayoutGrid size={16} /></button
>
<button
type="button"
aria-pressed={view === 'list'}
aria-label="List"
onclick={() => setView('list')}><ListIcon size={16} /></button
>
</div>
</div>
</div>
<div class="tabs" role="tablist" aria-label="Status">
{#each ['all', ...readingStatuses] as const as s (s)}
<button
type="button"
role="tab"
aria-selected={status === s}
onclick={() => (status = s)}
class={[status === s && 'active']}
>{s === 'all' ? 'all' : statusLabels[s]} <span>{counts[s]}</span></button
>
{/each}
{#if counts.none}
<button
type="button"
role="tab"
aria-selected={status === 'none'}
onclick={() => (status = 'none')}
class={[status === 'none' && 'active']}>unsorted <span>{counts.none}</span></button
>
{/if}
</div>
{#if filtering}
<p class="mb-4 flex items-center gap-2 text-sm text-[var(--site-muted)]">
{filtered.length} of {data.books.length} books
<button type="button" class="clear" onclick={clearFilters}><X size={12} /> clear</button>
</p>
{/if}
{#if !data.books.length}
<div class="empty">
<BookOpen size={28} aria-hidden="true" />
<p>The library is still being catalogued. Check back soon.</p>
</div>
{:else if !filtered.length}
<div class="empty"><p>No books match these filters.</p></div>
{:else if view === 'grid'}
<ul class="grid">
{#each filtered as b (b.id)}
<li>
<a
href={bookHref(b)}
class="card"
title={b.subtitle ? `${b.title}: ${b.subtitle}` : b.title}
>
<div class="cover-wrap">
<BookCover
src={b.cover}
title={b.title}
authors={b.authors}
placeholder={data.owner && !b.hasFile}
/>
{#if data.owner && !b.hasFile}
<span class="badge" title="No file yet"><FileQuestion size={12} /> no file</span>
{/if}
{#if b.status === 'finished'}<span class="done" title="Finished">✓</span>{/if}
<div class="cover-bar">{@render progressBar(b, true)}</div>
</div>
<span class="mt-2.5 line-clamp-2 text-sm leading-snug font-medium">{b.title}</span>
<span class="mt-0.5 truncate text-xs text-[var(--site-muted)]"
>{authorLine(b.authors) || 'unknown author'}</span
>
<span class="mt-1 flex min-h-4 items-center gap-2">
<Stars value={b.rating} size={11} />
{#if b.status === 'reading'}<span
class="text-xs text-[var(--site-muted)] tabular-nums">{pct(b.percent)}</span
>{/if}
</span>
</a>
</li>
{/each}
</ul>
{:else}
<ul class="list">
{#each filtered as b (b.id)}
<li>
<a href={bookHref(b)} class="row">
<span class="w-10 shrink-0"
><BookCover
src={b.cover}
title={b.title}
size="sm"
placeholder={data.owner && !b.hasFile}
/></span
>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium">{b.title}</span>
<span class="block truncate text-sm text-[var(--site-muted)]"
>{authorLine(b.authors) || 'unknown author'}{b.year ? ` · ${b.year}` : ''}</span
>
</span>
<span class="hidden w-28 sm:block">{@render statusChip(b.status)}</span>
<span class="hidden w-32 items-center gap-2 md:flex">
{#if b.percent > 0}<span class="bar grow"
><span style:width={pct(b.percent)}></span></span
><span class="w-9 text-right text-xs text-[var(--site-muted)] tabular-nums"
>{pct(b.percent)}</span
>{/if}
</span>
<span class="hidden w-20 lg:block"><Stars value={b.rating} size={11} /></span>
<span class="hidden w-24 text-right text-xs text-[var(--site-muted)] lg:block">
{#if b.lastReadAt}{dateFormat.format(new Date(b.lastReadAt))}{/if}
</span>
{#if data.owner && !b.hasFile}
<span class="badge static" title="No file yet"><FileQuestion size={12} /></span>
{/if}
</a>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
@media (min-width: 768px) {
.stats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.stats > div {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 0.9rem 1.1rem;
}
.stats dt {
font-size: 0.8rem;
color: var(--site-muted);
}
.stats dd {
margin-top: 0.15rem;
font-size: 1.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.stats small {
font-size: 0.75rem;
font-weight: 400;
color: var(--site-muted);
letter-spacing: 0;
}
.now {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
}
.now-card {
display: flex;
gap: 1rem;
align-items: flex-start;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 0.9rem;
}
.now-cover {
width: 4.5rem;
flex-shrink: 0;
}
.continue {
margin-top: 0.75rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
font-weight: 500;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.2rem 0.7rem;
transition: background 0.15s;
}
.continue:hover {
background: var(--site-hover);
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.search {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 16rem;
max-width: 26rem;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
padding: 0 0.75rem;
color: var(--site-muted);
background: var(--site-surface);
}
.search:focus-within {
border-color: var(--site-muted);
}
.toggle {
display: inline-flex;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
overflow: hidden;
}
.toggle button {
padding: 0.45rem 0.6rem;
color: var(--site-muted);
}
.toggle button[aria-pressed='true'] {
background: var(--site-hover);
color: var(--site-text);
}
.tabs {
display: flex;
gap: 0.25rem;
overflow-x: auto;
border-bottom: 1px solid var(--site-border);
margin-bottom: 1.5rem;
scrollbar-width: none;
}
.tabs button {
white-space: nowrap;
padding: 0.55rem 0.75rem;
font-size: 0.875rem;
color: var(--site-muted);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tabs button:hover {
color: var(--site-text);
}
.tabs button.active {
color: var(--site-text);
border-color: var(--site-text);
}
.tabs button span {
font-size: 0.75rem;
opacity: 0.6;
font-variant-numeric: tabular-nums;
}
.clear {
display: inline-flex;
align-items: center;
gap: 0.2rem;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0 0.5rem;
font-size: 0.75rem;
}
.grid {
display: grid;
gap: 1.75rem 1.1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 480px) {
.grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
.card {
display: flex;
flex-direction: column;
min-width: 0;
}
.cover-wrap {
position: relative;
transition:
transform 0.2s ease,
filter 0.2s ease;
}
.card:hover .cover-wrap,
.card:focus-visible .cover-wrap {
transform: translateY(-4px) rotate(-0.4deg);
filter: drop-shadow(0 12px 18px rgb(0 0 0 / 0.35));
}
.cover-bar {
position: absolute;
left: 8%;
right: 6%;
bottom: 6%;
}
.badge {
position: absolute;
top: 6px;
right: 6px;
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 0.65rem;
font-weight: 600;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: rgb(0 0 0 / 0.7);
color: #fff;
backdrop-filter: blur(4px);
}
.badge.static {
position: static;
background: var(--site-hover);
color: var(--site-muted);
}
.done {
position: absolute;
top: 6px;
left: 10%;
display: grid;
place-items: center;
width: 1.3rem;
height: 1.3rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
background: #16a34a;
color: #fff;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.4);
}
.bar {
display: block;
height: 0.35rem;
border-radius: 999px;
background: color-mix(in srgb, var(--site-muted) 25%, transparent);
overflow: hidden;
}
.bar > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--site-text);
}
.bar.thin {
height: 0.3rem;
background: rgb(0 0 0 / 0.45);
backdrop-filter: blur(4px);
}
.bar.thin > span {
background: #fff;
}
.list {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
overflow: hidden;
}
.list li + li {
border-top: 1px solid var(--site-border);
}
.row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
transition: background 0.15s;
}
.row:hover {
background: var(--site-hover);
}
.chip {
display: inline-block;
font-size: 0.72rem;
padding: 0.1rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--site-border);
color: var(--site-muted);
}
.chip.s-reading {
color: #2563eb;
border-color: color-mix(in srgb, #2563eb 40%, transparent);
}
.chip.s-finished {
color: #16a34a;
border-color: color-mix(in srgb, #16a34a 40%, transparent);
}
.chip.s-abandoned {
color: #b45309;
border-color: color-mix(in srgb, #b45309 40%, transparent);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 4rem 1rem;
border: 1px dashed var(--site-border);
border-radius: 0.75rem;
color: var(--site-muted);
text-align: center;
}
</style>

View File

@@ -0,0 +1,182 @@
import { error, fail } from '@sveltejs/kit';
import { and, eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { bookSessions } from '#lib/server/db/schema/index.js';
import { isOwner, requireOwner } from '#lib/server/owner.js';
import {
coverSrc,
getWork,
logManualSession,
setManualProgress,
setRating,
setStatus,
workFiles,
workNotes,
workSessions
} from '#lib/server/books/library.js';
import { createNote, deleteNote, updateNote } from '#lib/server/books/notes.js';
import { isReadingStatus } from '#lib/books/shared.js';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const id = Number(event.params.id);
const found = await getWork(id);
if (!found) error(404, 'Not Found');
const owner = isOwner(event);
const { work, progress } = found;
const book = {
id: work.id,
title: work.title,
subtitle: work.subtitle,
authors: work.authors,
year: work.year,
publisher: work.publisher,
isbn13: work.isbn13,
isbn10: work.isbn10,
language: work.language,
pageCount: work.pageCount,
description: work.description,
tags: work.tags,
cover: coverSrc(work),
openlibraryWorkId: work.openlibraryWorkId
};
const reading = {
status: progress?.status ?? null,
percent: progress?.percent ?? 0,
page: progress?.page ?? null,
rating: progress?.rating ?? null,
startedAt: progress?.startedAt?.toISOString() ?? null,
finishedAt: progress?.finishedAt?.toISOString() ?? null,
lastReadAt: progress?.lastReadAt?.toISOString() ?? null
};
const seo = {
title: `${work.title}${work.authors.length ? ` by ${work.authors.join(', ')}` : ''}`,
description:
work.description?.slice(0, 160) ??
`${work.title} on Stan Runge's bookshelf${reading.status ? ` (${reading.status.replace(/_/g, ' ')})` : ''}.`
};
if (!owner) return { book, reading, owner: false as const, seo };
event.setHeaders({ 'cache-control': 'private, no-store' });
const [files, notes, sessions] = await Promise.all([
workFiles(id),
workNotes(id),
workSessions(id)
]);
return {
book,
reading,
owner: true as const,
seo,
files: files.map(({ id, format, sizeBytes, readable, path }) => ({
id,
format,
sizeBytes,
readable,
name: path.split('/').at(-1) ?? path
})),
notes,
sessions
};
};
const int = (value: FormDataEntryValue | null) => {
if (value === null || value === '') return null;
const n = Number(value);
return Number.isInteger(n) ? n : NaN;
};
const date = (value: FormDataEntryValue | null) => {
if (!value) return null;
const d = new Date(String(value));
return Number.isNaN(d.getTime()) ? undefined : d;
};
export const actions: Actions = {
status: async (event) => {
requireOwner(event);
const status = (await event.request.formData()).get('status');
if (!isReadingStatus(status)) return fail(400, { message: 'Unknown status' });
await setStatus(Number(event.params.id), status);
return { ok: true };
},
rating: async (event) => {
requireOwner(event);
const rating = int((await event.request.formData()).get('rating'));
if (rating !== null && !(rating >= 1 && rating <= 5))
return fail(400, { message: 'Rating is 1-5' });
await setRating(Number(event.params.id), rating);
return { ok: true };
},
progress: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const page = int(form.get('page'));
const pageCount = int(form.get('pageCount'));
let percent = form.get('percent') === '' ? null : Number(form.get('percent'));
if (Number.isNaN(page) || (percent !== null && !(percent >= 0 && percent <= 100)))
return fail(400, { message: 'Invalid progress' });
if (percent === null && page !== null && pageCount)
percent = Math.min(100, (page / pageCount) * 100);
if (percent === null) return fail(400, { message: 'Enter a percent or a page' });
await setManualProgress(Number(event.params.id), Math.round(percent * 100) / 100, page);
return { ok: true };
},
addNote: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const note = String(form.get('note') ?? '').trim();
const page = int(form.get('page'));
if (!note) return fail(400, { message: 'Write something first' });
if (Number.isNaN(page)) return fail(400, { message: 'Invalid page' });
await createNote(Number(event.params.id), { note, page });
return { ok: true };
},
editNote: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const id = int(form.get('id'));
if (!id) return fail(400, { message: 'Missing note' });
const updated = await updateNote(Number(event.params.id), id, {
note: String(form.get('note') ?? '').trim() || null
});
if (!updated) return fail(404, { message: 'Note not found' });
return { ok: true };
},
deleteNote: async (event) => {
requireOwner(event);
const id = int((await event.request.formData()).get('id'));
if (!id || !(await deleteNote(Number(event.params.id), id)))
return fail(404, { message: 'Note not found' });
return { ok: true };
},
logSession: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const startedAt = date(form.get('startedAt'));
const endedAt = date(form.get('endedAt'));
const startPage = int(form.get('startPage'));
const endPage = int(form.get('endPage'));
if (!startedAt || endedAt === undefined || Number.isNaN(startPage) || Number.isNaN(endPage))
return fail(400, { message: 'Check the dates and pages' });
if (endedAt && endedAt < startedAt) return fail(400, { message: 'End is before start' });
await logManualSession(Number(event.params.id), {
startedAt,
endedAt,
startPage,
endPage,
startPercent: null,
endPercent: null,
note: String(form.get('note') ?? '').trim() || null
});
return { ok: true };
},
deleteSession: async (event) => {
requireOwner(event);
const id = int((await event.request.formData()).get('id'));
if (!id) return fail(400, { message: 'Missing session' });
await db
.delete(bookSessions)
.where(and(eq(bookSessions.id, id), eq(bookSessions.workId, Number(event.params.id))));
return { ok: true };
}
};

View File

@@ -0,0 +1,683 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import BookCover from '#lib/books/BookCover.svelte';
import Stars from '#lib/books/Stars.svelte';
import {
highlightCss,
readingStatuses,
statusLabels,
type HighlightColor
} from '#lib/books/shared.js';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import BookOpen from 'lucide-svelte/icons/book-open';
import Highlighter from 'lucide-svelte/icons/highlighter';
import NotebookPen from 'lucide-svelte/icons/notebook-pen';
import History from 'lucide-svelte/icons/history';
import Trash2 from 'lucide-svelte/icons/trash-2';
import Pencil from 'lucide-svelte/icons/pencil';
import FileQuestion from 'lucide-svelte/icons/file-question';
import Lock from 'lucide-svelte/icons/lock';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
const book = $derived(data.book);
const reading = $derived(data.reading);
let descriptionOpen = $state(false);
let editing = $state<number | null>(null);
let showSessionForm = $state(false);
const dateFormat = new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'Europe/Amsterdam'
});
const timeFormat = new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Amsterdam'
});
const localInput = (d: Date) =>
new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Amsterdam',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
.format(d)
.replace(' ', 'T');
const size = (bytes: number) =>
bytes > 1e6 ? `${(bytes / 1e6).toFixed(1)} MB` : `${Math.ceil(bytes / 1e3)} kB`;
const minutes = (a: string, b: string | null) =>
b ? Math.max(1, Math.round((new Date(b).getTime() - new Date(a).getTime()) / 60000)) : null;
const readable = $derived(data.owner ? data.files.find((f) => f.readable) : undefined);
const highlights = $derived(data.owner ? data.notes.filter((n) => n.highlight) : []);
const freeNotes = $derived(data.owner ? data.notes.filter((n) => !n.highlight) : []);
const readHref = $derived(resolve('/trackers/books/[id=integer]/read', { id: String(book.id) }));
const meta = $derived(
[
['published', book.year],
['publisher', book.publisher],
['pages', book.pageCount],
['language', book.language?.toUpperCase()],
['ISBN', book.isbn13 ?? book.isbn10]
].filter(([, v]) => v != null && v !== '') as [string, string | number][]
);
const keep: SubmitFunction =
() =>
async ({ update }) => {
await update({ reset: false });
};
const done: SubmitFunction =
() =>
async ({ result, update }) => {
await update();
if (result.type === 'success') {
editing = null;
showSessionForm = false;
}
};
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/trackers/books')}
class="mb-8 inline-flex items-center gap-1.5 text-sm text-[var(--site-muted)] hover:text-[var(--site-text)]"
><ArrowLeft size={14} /> all books</a
>
<div class="layout">
<aside class="side">
<div class="cover-box">
<BookCover
src={book.cover}
title={book.title}
authors={book.authors}
size="lg"
placeholder={data.owner && !data.files.length}
/>
</div>
{#if data.owner}
{#if readable}
<Button href={readHref} class="mt-5 w-full gap-2" size="lg"
><BookOpen size={16} />
{reading.percent > 0 && reading.status !== 'finished'
? 'continue reading'
: 'read'}</Button
>
{:else if data.files.length}
<p class="mt-5 text-center text-sm text-[var(--site-muted)]">
{data.files.map((f) => f.format.toUpperCase()).join(', ')} can't be opened in the browser
reader yet.
</p>
{:else}
<p class="placeholder-note">
<FileQuestion size={16} /> No file yet. This is a placeholder until a copy lands in the library.
</p>
{/if}
<form method="POST" action="?/status" use:enhance={keep} class="status-group">
{#each readingStatuses as s (s)}
<button
name="status"
value={s}
class={['seg', reading.status === s && 'active']}
aria-pressed={reading.status === s}>{statusLabels[s]}</button
>
{/each}
</form>
<form method="POST" action="?/rating" use:enhance={keep} class="mt-3 flex justify-center">
<Stars value={reading.rating} size={22} name="rating" />
</form>
{:else}
<div class="mt-5 flex flex-col items-center gap-2">
{#if reading.status}<span class={['chip', `s-${reading.status}`]}
>{statusLabels[reading.status]}</span
>{/if}
<Stars value={reading.rating} size={18} />
</div>
{/if}
</aside>
<div class="min-w-0">
<h1 class="text-3xl leading-tight font-semibold tracking-tight sm:text-4xl">{book.title}</h1>
{#if book.subtitle}<p class="mt-2 text-lg text-[var(--site-muted)]">{book.subtitle}</p>{/if}
{#if book.authors.length}<p class="mt-3 text-lg">{book.authors.join(', ')}</p>{/if}
{#if meta.length}
<dl class="meta">
{#each meta as [label, value] (label)}
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
{/each}
</dl>
{/if}
{#if book.tags.length}
<ul class="mt-5 flex flex-wrap gap-1.5">
{#each book.tags.slice(0, 16) as tag (tag)}<li class="tag">{tag}</li>{/each}
</ul>
{/if}
{#if book.description}
<div class="mt-6">
<p class={['description', !descriptionOpen && 'clamped']}>{book.description}</p>
{#if book.description.length > 400}
<button
type="button"
class="mt-1 text-sm text-[var(--site-muted)] underline-offset-2 hover:underline"
onclick={() => (descriptionOpen = !descriptionOpen)}
>{descriptionOpen ? 'less' : 'more'}</button
>
{/if}
</div>
{/if}
<section class="panel mt-8">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h2 class="text-lg font-semibold tracking-tight">progress</h2>
<p class="text-sm text-[var(--site-muted)]">
{#if reading.startedAt}started {dateFormat.format(new Date(reading.startedAt))}{/if}
{#if reading.finishedAt}· finished {dateFormat.format(
new Date(reading.finishedAt)
)}{/if}
{#if reading.lastReadAt && reading.status === 'reading'}· last read {timeFormat.format(
new Date(reading.lastReadAt)
)}{/if}
</p>
</div>
<div class="mt-3 flex items-center gap-3">
<span class="bar grow"><span style:width={`${reading.percent}%`}></span></span>
<span class="text-sm font-medium tabular-nums">{Math.round(reading.percent)}%</span>
</div>
{#if reading.page && book.pageCount}
<p class="mt-1 text-xs text-[var(--site-muted)]">
page {reading.page} of {book.pageCount}
</p>
{/if}
{#if data.owner}
<form
method="POST"
action="?/progress"
use:enhance={done}
class="mt-4 flex flex-wrap items-end gap-2"
>
<label class="field"
><span>percent</span><Input
name="percent"
type="number"
min="0"
max="100"
step="0.1"
class="w-24"
value={reading.percent ? Math.round(reading.percent * 10) / 10 : ''}
/></label
>
{#if book.pageCount}
<label class="field"
><span>or page</span><Input
name="page"
type="number"
min="0"
max={book.pageCount}
class="w-24"
value={reading.page ?? ''}
/></label
>
<input type="hidden" name="pageCount" value={book.pageCount} />
{/if}
<Button type="submit" variant="outline" size="sm">update</Button>
</form>
{/if}
</section>
{#if data.owner}
{#if form?.message}<p class="mt-4 text-sm text-red-500" role="alert">{form.message}</p>{/if}
<section class="mt-10">
<h2 class="section-title"><NotebookPen size={18} /> notes</h2>
<form method="POST" action="?/addNote" use:enhance={done} class="mb-4 space-y-2">
<Textarea name="note" rows={3} placeholder="Thoughts, quotes, a summary…" required />
<div class="flex items-center gap-2">
<Input name="page" type="number" min="0" placeholder="page" class="w-24" />
<Button type="submit" size="sm">add note</Button>
</div>
</form>
{#if freeNotes.length}
<ul class="space-y-3">
{#each freeNotes as n (n.id)}
<li class="note">
{#if editing === n.id}
<form method="POST" action="?/editNote" use:enhance={done} class="space-y-2">
<input type="hidden" name="id" value={n.id} />
<Textarea name="note" rows={4} value={n.note ?? ''} />
<div class="flex gap-2">
<Button type="submit" size="sm">save</Button>
<Button
type="button"
size="sm"
variant="ghost"
onclick={() => (editing = null)}>cancel</Button
>
</div>
</form>
{:else}
<p class="whitespace-pre-wrap">{n.note}</p>
<footer>
<span
>{dateFormat.format(new Date(n.createdAt))}{n.page
? ` · p. ${n.page}`
: ''}</span
>
<span class="flex gap-1">
<button
type="button"
class="icon"
aria-label="Edit note"
onclick={() => (editing = n.id)}><Pencil size={13} /></button
>
<form method="POST" action="?/deleteNote" use:enhance={done}>
<input type="hidden" name="id" value={n.id} />
<button class="icon" aria-label="Delete note"><Trash2 size={13} /></button
>
</form>
</span>
</footer>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="text-sm text-[var(--site-muted)]">No notes yet.</p>
{/if}
</section>
<section class="mt-10">
<h2 class="section-title"><Highlighter size={18} /> highlights</h2>
{#if highlights.length}
<ul class="space-y-3">
{#each highlights as h (h.id)}
<li
class="highlight"
style:--hl={highlightCss[(h.color as HighlightColor) ?? 'yellow'] ??
highlightCss.yellow}
>
<blockquote>{h.highlight}</blockquote>
{#if editing === h.id}
<form
method="POST"
action="?/editNote"
use:enhance={done}
class="mt-2 space-y-2"
>
<input type="hidden" name="id" value={h.id} />
<Textarea
name="note"
rows={3}
value={h.note ?? ''}
placeholder="Add a note"
/>
<div class="flex gap-2">
<Button type="submit" size="sm">save</Button>
<Button
type="button"
size="sm"
variant="ghost"
onclick={() => (editing = null)}>cancel</Button
>
</div>
</form>
{:else if h.note}
<p class="mt-2 text-sm whitespace-pre-wrap">{h.note}</p>
{/if}
<footer>
<span>
{dateFormat.format(new Date(h.createdAt))}{h.page ? ` · p. ${h.page}` : ''}
{#if readable && h.locator}
· <a
class="underline-offset-2 hover:underline"
href={`${readHref}?at=${encodeURIComponent(h.locator)}`}>open in reader</a
>
{/if}
</span>
<span class="flex gap-1">
<button
type="button"
class="icon"
aria-label="Edit note"
onclick={() => (editing = h.id)}><Pencil size={13} /></button
>
<form method="POST" action="?/deleteNote" use:enhance={done}>
<input type="hidden" name="id" value={h.id} />
<button class="icon" aria-label="Delete highlight"
><Trash2 size={13} /></button
>
</form>
</span>
</footer>
</li>
{/each}
</ul>
{:else}
<p class="text-sm text-[var(--site-muted)]">
Select text in the reader to highlight it{readable ? '' : ' (once there is a file)'}.
</p>
{/if}
</section>
<section class="mt-10">
<div class="mb-3 flex items-center justify-between">
<h2 class="section-title mb-0"><History size={18} /> reading history</h2>
<Button size="sm" variant="outline" onclick={() => (showSessionForm = !showSessionForm)}
>{showSessionForm ? 'cancel' : 'log a session'}</Button
>
</div>
{#if showSessionForm}
<form
method="POST"
action="?/logSession"
use:enhance={done}
class="panel mb-4 grid gap-3 sm:grid-cols-2"
>
<label class="field"
><span>start</span><Input
name="startedAt"
type="datetime-local"
required
value={localInput(new Date(Date.now() - 30 * 60000))}
/></label
>
<label class="field"
><span>end</span><Input
name="endedAt"
type="datetime-local"
value={localInput(new Date())}
/></label
>
<label class="field"
><span>from page</span><Input
name="startPage"
type="number"
min="0"
value={reading.page ?? ''}
/></label
>
<label class="field"
><span>to page</span><Input name="endPage" type="number" min="0" /></label
>
<label class="field sm:col-span-2"
><span>note</span><Input name="note" placeholder="optional" /></label
>
<div class="sm:col-span-2"><Button type="submit" size="sm">save session</Button></div>
</form>
{/if}
{#if data.sessions.length}
<ul class="sessions">
{#each data.sessions as s (s.id)}
<li>
<span class="min-w-0 flex-1">
<span class="block">{timeFormat.format(new Date(s.startedAt))}</span>
<span class="block text-xs text-[var(--site-muted)]">
{#if minutes(s.startedAt, s.endedAt)}{minutes(s.startedAt, s.endedAt)} min{/if}
{#if s.startPercent != null && s.endPercent != null}
· {Math.round(s.startPercent)}% → {Math.round(s.endPercent)}%{/if}
{#if s.startPage != null && s.endPage != null}
· p. {s.startPage} → {s.endPage}{/if}
{#if s.source === 'manual'}· logged by hand{/if}
{#if s.note}· {s.note}{/if}
</span>
</span>
{#if s.pagesRead}<span class="pages">{s.pagesRead} p.</span>{/if}
<form method="POST" action="?/deleteSession" use:enhance={done}>
<input type="hidden" name="id" value={s.id} />
<button class="icon" aria-label="Delete session"><Trash2 size={13} /></button>
</form>
</li>
{/each}
</ul>
{:else}
<p class="text-sm text-[var(--site-muted)]">
Sessions are logged automatically while you read.
</p>
{/if}
</section>
{#if data.files.length}
<section class="mt-10">
<h2 class="section-title"><Lock size={16} /> files</h2>
<ul class="text-sm text-[var(--site-muted)]">
{#each data.files as f (f.id)}
<li class="flex justify-between gap-4 py-1">
<span class="truncate">{f.name}</span>
<span class="shrink-0 tabular-nums"
>{f.format.toUpperCase()} · {size(f.sizeBytes)}</span
>
</li>
{/each}
</ul>
</section>
{/if}
{/if}
</div>
</div>
</div>
<style>
.layout {
display: grid;
gap: 2.5rem;
}
@media (min-width: 768px) {
.layout {
grid-template-columns: 16rem minmax(0, 1fr);
gap: 3.5rem;
}
.side {
position: sticky;
top: 6rem;
align-self: start;
}
}
.cover-box {
max-width: 14rem;
margin: 0 auto;
}
@media (min-width: 768px) {
.cover-box {
max-width: none;
}
}
.placeholder-note {
margin-top: 1.25rem;
display: flex;
gap: 0.5rem;
align-items: flex-start;
font-size: 0.85rem;
color: var(--site-muted);
border: 1px dashed var(--site-border);
border-radius: 0.5rem;
padding: 0.6rem 0.75rem;
}
.status-group {
margin-top: 1rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.35rem;
}
.seg {
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
border-radius: 0.5rem;
border: 1px solid var(--site-border);
color: var(--site-muted);
transition:
background 0.15s,
color 0.15s;
}
.seg:hover {
background: var(--site-hover);
color: var(--site-text);
}
.seg.active {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.meta {
margin-top: 1.5rem;
display: flex;
flex-wrap: wrap;
gap: 1rem 2rem;
}
.meta dt {
font-size: 0.75rem;
color: var(--site-muted);
}
.meta dd {
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.tag {
font-size: 0.75rem;
padding: 0.15rem 0.6rem;
border-radius: 999px;
background: var(--site-hover);
color: var(--site-muted);
}
.description {
line-height: 1.7;
white-space: pre-line;
}
.description.clamped {
display: -webkit-box;
-webkit-line-clamp: 6;
line-clamp: 6;
-webkit-box-orient: vertical;
overflow: hidden;
}
.panel {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 1rem 1.25rem;
}
.bar {
display: block;
height: 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--site-muted) 25%, transparent);
overflow: hidden;
}
.bar > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--site-text);
transition: width 0.4s ease;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--site-muted);
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.01em;
margin-bottom: 0.75rem;
}
.note,
.highlight {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
padding: 0.85rem 1rem;
background: var(--site-surface);
}
.highlight blockquote {
border-left: 3px solid var(--hl);
padding-left: 0.85rem;
font-family: Georgia, 'Times New Roman', serif;
line-height: 1.6;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--hl) 12%, transparent),
transparent 70%
);
}
.note footer,
.highlight footer {
margin-top: 0.6rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
color: var(--site-muted);
}
.icon {
display: inline-flex;
border-radius: 0.375rem;
padding: 0.3rem;
color: var(--site-muted);
}
.icon:hover {
background: var(--site-hover);
color: var(--site-text);
}
.sessions {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
overflow: hidden;
}
.sessions li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 0.9rem;
font-size: 0.875rem;
}
.sessions li + li {
border-top: 1px solid var(--site-border);
}
.pages {
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
color: var(--site-muted);
}
.chip {
display: inline-block;
font-size: 0.8rem;
padding: 0.15rem 0.7rem;
border-radius: 999px;
border: 1px solid var(--site-border);
color: var(--site-muted);
}
.chip.s-reading {
color: #2563eb;
}
.chip.s-finished {
color: #16a34a;
}
.chip.s-abandoned {
color: #b45309;
}
</style>

View File

@@ -0,0 +1,14 @@
// Owner-only: create a highlight or note from the reader.
import { error, json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { createNote, notePayload } from '#lib/server/books/notes.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
requireOwner(event);
const parsed = notePayload.safeParse(await event.request.json().catch(() => null));
if (!parsed.success) error(400, 'Invalid note');
const note = await createNote(Number(event.params.id), parsed.data);
if (!note) error(404, 'Not Found');
return json(note, { status: 201 });
};

View File

@@ -0,0 +1,21 @@
// Owner-only: edit or delete one highlight/note.
import { error, json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { deleteNote, noteUpdate, updateNote } from '#lib/server/books/notes.js';
import type { RequestHandler } from './$types';
export const PATCH: RequestHandler = async (event) => {
requireOwner(event);
const parsed = noteUpdate.safeParse(await event.request.json().catch(() => null));
if (!parsed.success) error(400, 'Invalid note');
const note = await updateNote(Number(event.params.id), Number(event.params.noteId), parsed.data);
if (!note) error(404, 'Not Found');
return json(note);
};
export const DELETE: RequestHandler = async (event) => {
requireOwner(event);
const ok = await deleteNote(Number(event.params.id), Number(event.params.noteId));
if (!ok) error(404, 'Not Found');
return new Response(null, { status: 204 });
};

View File

@@ -0,0 +1,21 @@
// Owner-only: the reader saves its position here (debounced, and via sendBeacon on close).
import { error, json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { parseProgress } from '#lib/books/progress.js';
import { saveProgress } from '#lib/server/books/library.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
requireOwner(event);
let body: unknown;
try {
body = JSON.parse(await event.request.text());
} catch {
error(400, 'Invalid JSON');
}
const input = parseProgress(body);
if (!input) error(400, 'Invalid progress');
const result = await saveProgress(Number(event.params.id), input);
if (!result) error(404, 'Not Found');
return json(result, { headers: { 'cache-control': 'private, no-store' } });
};

View File

@@ -0,0 +1,52 @@
// Owner-only in-browser reader. Everyone else gets the same 404 as a missing page.
import { error } from '@sveltejs/kit';
import { resolve } from '$app/paths';
import { requireOwner } from '#lib/server/owner.js';
import { getWork, workFiles, workNotes } from '#lib/server/books/library.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
const id = Number(event.params.id);
const found = await getWork(id);
if (!found) error(404, 'Not Found');
const { work, progress } = found;
const files = (await workFiles(id)).filter((f) => f.readable);
const wanted = Number(event.url.searchParams.get('file'));
const file =
files.find((f) => f.id === wanted) ?? files.find((f) => f.id === progress?.fileId) ?? files[0];
if (!file) error(404, 'No readable file for this book yet');
// Resume where we left off, but only if the saved position belongs to this file.
const sameFile = progress?.fileId === file.id;
const notes = (await workNotes(id)).filter(
(n) => n.highlight && n.locator && (n.fileId === file.id || n.fileId === null)
);
return {
book: {
id: work.id,
title: work.title,
authors: work.authors,
pageCount: work.pageCount
},
file: {
id: file.id,
format: file.format,
engine: file.format === 'pdf' ? ('pdf' as const) : ('foliate' as const),
url: resolve('/trackers/books/files/[id=integer]', { id: String(file.id) }),
name: file.path.split('/').at(-1) ?? `book.${file.format}`
},
otherFiles: files.filter((f) => f.id !== file.id).map((f) => ({ id: f.id, format: f.format })),
resume: {
locator: sameFile ? (progress?.locator ?? null) : null,
percent: sameFile ? (progress?.percent ?? 0) : 0,
page: sameFile ? (progress?.page ?? null) : null
},
jumpTo: event.url.searchParams.get('at'),
highlights: notes,
seo: { title: `Reading ${work.title}`, description: work.title, index: false }
};
};

View File

@@ -0,0 +1,999 @@
<script lang="ts">
import { onMount } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import { resolve } from '$app/paths';
import EpubReader from '#lib/books/reader/EpubReader.svelte';
import PdfReader from '#lib/books/reader/PdfReader.svelte';
import {
defaultSettings,
themeColors,
type ReaderEngine,
type ReaderSettings,
type ReaderTheme,
type Relocation,
type TextSelection,
type TocItem
} from '#lib/books/reader/types.js';
import {
highlightColors,
highlightCss,
type Highlight,
type HighlightColor
} from '#lib/books/shared.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import ListTree from 'lucide-svelte/icons/list-tree';
import Highlighter from 'lucide-svelte/icons/highlighter';
import Type from 'lucide-svelte/icons/type';
import X from 'lucide-svelte/icons/x';
import Trash2 from 'lucide-svelte/icons/trash-2';
import Copy from 'lucide-svelte/icons/copy';
import MessageSquarePlus from 'lucide-svelte/icons/message-square-plus';
import Minus from 'lucide-svelte/icons/minus';
import Plus from 'lucide-svelte/icons/plus';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const SETTINGS_KEY = 'books-reader-settings';
const isPdf = $derived(data.file.engine === 'pdf');
const progressUrl = $derived(
resolve('/trackers/books/[id=integer]/progress', { id: String(data.book.id) })
);
const notesUrl = $derived(
resolve('/trackers/books/[id=integer]/notes', { id: String(data.book.id) })
);
const detailUrl = $derived(resolve('/trackers/books/[id=integer]', { id: String(data.book.id) }));
let engine = $state<ReaderEngine>();
let settings = $state<ReaderSettings>({ ...defaultSettings });
let siteDark = $state(true);
// Seeded from the load; the reader adds/edits/deletes in place (writable derived).
let highlights = $derived<Highlight[]>(data.highlights);
let toc = $state<TocItem[]>([]);
let ready = $state(false);
let loading = $state<number | null>(null);
let failure = $state<string | null>(null);
let location = $state<Relocation | null>(null);
let panel = $state<'toc' | 'highlights' | 'settings' | null>(null);
let selection = $state<TextSelection | null>(null);
let noteFor = $state<'selection' | number | null>(null);
let noteDraft = $state('');
let activeId = $state<number | null>(null);
let saveState = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
let scrubbing = $state<number | null>(null);
const theme = $derived<ReaderTheme>(
settings.theme === 'auto' ? (siteDark ? 'dark' : 'light') : settings.theme
);
const colors = $derived(themeColors[theme]);
const active = $derived(highlights.find((h) => h.id === activeId) ?? null);
const percent = $derived(scrubbing ?? location?.percent ?? data.resume.percent);
const start = $derived(data.jumpTo ?? data.resume.locator);
onMount(() => {
try {
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? 'null');
if (saved && typeof saved === 'object') settings = { ...defaultSettings, ...saved };
} catch {
// ignore broken settings
}
siteDark = !getComputedStyle(document.documentElement).colorScheme.includes('light');
const onHide = () => flush(true);
const onVisibility = () => document.visibilityState === 'hidden' && flush(true);
window.addEventListener('pagehide', onHide);
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.removeEventListener('pagehide', onHide);
document.removeEventListener('visibilitychange', onVisibility);
flush(true);
};
});
function updateSettings(patch: Partial<ReaderSettings>) {
settings = { ...settings, ...patch };
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
// ---- progress saving: debounced while reading, flushed on leave ----
let sessionId: number | null = null;
let pending: Relocation | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
let baseline: string | null = null;
function onrelocate(r: Relocation) {
location = r;
if (!ready) return;
if (baseline === null) {
baseline = r.locator; // the restored position itself isn't reading
return;
}
if (r.locator === baseline && !pending) return;
pending = r;
clearTimeout(timer);
timer = setTimeout(() => flush(), 2000);
}
async function flush(leaving = false) {
clearTimeout(timer);
if (!pending) return;
const r = pending;
pending = null;
const body = JSON.stringify({
fileId: data.file.id,
locator: r.locator,
page: r.page,
percent: Math.round(r.percent * 100) / 100,
pageCount: r.pageCount,
sessionId
});
saveState = 'saving';
try {
const res = await fetch(progressUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
keepalive: leaving
});
if (!res.ok) throw new Error(String(res.status));
sessionId = (await res.json()).sessionId ?? sessionId;
saveState = 'saved';
} catch {
saveState = 'error';
if (!leaving) pending ??= r;
}
}
beforeNavigate(() => void flush(true));
// ---- highlights ----
async function createHighlight(color: HighlightColor, note: string | null = null) {
const sel = selection;
if (!sel) return;
const res = await fetch(notesUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
fileId: data.file.id,
locator: sel.locator,
page: sel.page ?? location?.page ?? null,
highlight: sel.text.slice(0, 20000),
note: note || null,
color
})
});
if (!res.ok) return;
const h: Highlight = await res.json();
highlights = [h, ...highlights];
engine?.addHighlight(h);
engine?.clearSelection();
selection = null;
noteFor = null;
noteDraft = '';
}
async function patchHighlight(
h: Highlight,
patch: { note?: string | null; color?: HighlightColor }
) {
const res = await fetch(`${notesUrl}/${h.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch)
});
if (!res.ok) return;
const updated: Highlight = await res.json();
highlights = highlights.map((x) => (x.id === h.id ? updated : x));
if (patch.color) engine?.addHighlight(updated);
}
async function removeHighlight(h: Highlight) {
const res = await fetch(`${notesUrl}/${h.id}`, { method: 'DELETE' });
if (!res.ok && res.status !== 404) return;
highlights = highlights.filter((x) => x.id !== h.id);
engine?.removeHighlight(h);
activeId = null;
}
function openHighlight(id: number) {
activeId = id;
noteDraft = highlights.find((h) => h.id === id)?.note ?? '';
noteFor = null;
}
function onselection(s: TextSelection | null) {
selection = s;
if (s) activeId = null;
else if (noteFor === 'selection') noteFor = null;
}
// ---- navigation ----
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) return;
switch (e.key) {
case 'ArrowRight':
case 'PageDown':
case 'l':
engine?.next();
break;
case 'ArrowLeft':
case 'PageUp':
case 'h':
engine?.prev();
break;
case ' ':
if (isPdf) return;
if (e.shiftKey) engine?.prev();
else engine?.next();
break;
case 'Escape':
if (panel || activeId || selection) {
panel = null;
activeId = null;
engine?.clearSelection();
selection = null;
} else return;
break;
case 't':
panel = panel === 'toc' ? null : 'toc';
break;
default:
return;
}
e.preventDefault();
}
async function jump(target: string | number) {
await engine?.goTo(target);
if (window.matchMedia('(max-width: 900px)').matches) panel = null;
}
const fmtPercent = (p: number) => `${p < 10 ? p.toFixed(1) : Math.round(p)}%`;
const tocLabel = $derived(location?.label ?? null);
</script>
<svelte:window onkeydown={onkey} />
<div class="reader" style:--r-bg={colors.bg} style:--r-fg={colors.fg} data-theme={theme}>
<header class="bar top">
<a href={detailUrl} class="icon-btn" aria-label="Back to the book"><ArrowLeft size={18} /></a>
<div class="min-w-0 flex-1 text-center">
<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>
<button
type="button"
class={['icon-btn', panel === 'toc' && 'on']}
aria-label="Contents"
title="Contents (t)"
onclick={() => (panel = panel === 'toc' ? null : 'toc')}><ListTree size={18} /></button
>
<button
type="button"
class={['icon-btn', panel === 'highlights' && 'on']}
aria-label="Highlights"
onclick={() => (panel = panel === 'highlights' ? null : 'highlights')}
><Highlighter size={18} />{#if highlights.length}<span class="count">{highlights.length}</span
>{/if}</button
>
<button
type="button"
class={['icon-btn', panel === 'settings' && 'on']}
aria-label="Display settings"
onclick={() => (panel = panel === 'settings' ? null : 'settings')}><Type size={18} /></button
>
</header>
<div class="stage">
<main class="book">
{#if isPdf}
<PdfReader
bind:this={engine}
url={data.file.url}
name={data.file.name}
{start}
startPercent={data.resume.percent}
{highlights}
{settings}
{theme}
onready={(t) => {
toc = t;
ready = true;
}}
{onrelocate}
{onselection}
onhighlightclick={openHighlight}
{onkey}
onloading={(f) => (loading = f)}
onerror={(m) => (failure = m)}
/>
{:else}
<EpubReader
bind:this={engine}
url={data.file.url}
name={data.file.name}
{start}
startPercent={data.resume.percent}
{highlights}
{settings}
{theme}
onready={(t) => {
toc = t;
ready = true;
}}
{onrelocate}
{onselection}
onhighlightclick={openHighlight}
{onkey}
onloading={(f) => (loading = f)}
onerror={(m) => (failure = m)}
/>
{#if ready && settings.flow === 'paginated'}
<button
type="button"
class="turn left"
aria-label="Previous page"
onclick={() => engine?.prev()}><ChevronLeft size={22} /></button
>
<button
type="button"
class="turn right"
aria-label="Next page"
onclick={() => engine?.next()}><ChevronRight size={22} /></button
>
{/if}
{/if}
{#if failure}
<div class="overlay">
<p class="font-medium">Couldn't open this book</p>
<p class="mt-1 text-sm opacity-70">{failure}</p>
<a href={detailUrl} class="mt-4 text-sm underline">back to the book</a>
</div>
{:else if !ready}
<div class="overlay">
<p class="text-sm opacity-70">
{loading === null || loading === 0
? 'Opening…'
: `Downloading ${Math.round(loading * 100)}%`}
</p>
<span class="loadbar"><span style:width={`${(loading ?? 0) * 100}%`}></span></span>
</div>
{/if}
</main>
{#if panel}
<aside class="panel" aria-label={panel}>
<div class="panel-head">
<h2>
{panel === 'toc' ? 'Contents' : panel === 'highlights' ? 'Highlights' : 'Display'}
</h2>
<button type="button" class="icon-btn" aria-label="Close" onclick={() => (panel = null)}
><X size={16} /></button
>
</div>
<div class="panel-body">
{#if panel === 'toc'}
{#snippet tree(items: TocItem[], depth: number)}
<ul>
{#each items as item, i (i)}
<li>
<button
type="button"
class={['toc-item', tocLabel === item.label && 'current']}
style:padding-left={`${0.75 + depth * 0.9}rem`}
onclick={() => jump(item.target)}>{item.label}</button
>
{#if item.children.length && depth < 4}{@render tree(
item.children,
depth + 1
)}{/if}
</li>
{/each}
</ul>
{/snippet}
{#if toc.length}{@render tree(toc, 0)}{:else}<p class="empty">
No table of contents in this file.
</p>{/if}
{:else if panel === 'highlights'}
{#if highlights.length}
<ul class="space-y-2">
{#each highlights as h (h.id)}
<li>
<button
type="button"
class="hl-item"
style:--hl={highlightCss[(h.color as HighlightColor) ?? 'yellow'] ??
highlightCss.yellow}
onclick={() => {
if (h.locator) void jump(h.locator);
openHighlight(h.id);
}}
>
<span class="quote">{h.highlight}</span>
{#if h.note}<span class="note">{h.note}</span>{/if}
{#if h.page}<span class="meta">p. {h.page}</span>{/if}
</button>
</li>
{/each}
</ul>
{:else}
<p class="empty">
Select text to highlight it. Highlights also show on the book page.
</p>
{/if}
{:else}
<div class="space-y-5">
<fieldset>
<legend>Theme</legend>
<div class="themes">
{#each ['auto', 'light', 'sepia', 'dark'] as const as t (t)}
<button
type="button"
class={['theme-swatch', settings.theme === t && 'on']}
style:background={t === 'auto'
? 'linear-gradient(135deg, #fdfdfb 50%, #121214 50%)'
: themeColors[t].bg}
style:color={t === 'auto' ? '#888' : themeColors[t].fg}
onclick={() => updateSettings({ theme: t })}>{t}</button
>
{/each}
</div>
</fieldset>
{#if isPdf}
<fieldset>
<legend>Zoom</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) })}
><Minus size={14} /></button
>
<span>{settings.fontSize}%</span>
<button
type="button"
aria-label="Larger text"
onclick={() =>
updateSettings({ fontSize: Math.min(220, settings.fontSize + 10) })}
><Plus size={14} /></button
>
</div>
</fieldset>
<fieldset>
<legend>Font</legend>
<div class="seg">
{#each [['publisher', 'Book'], ['serif', 'Serif'], ['sans', 'Sans']] as const as [value, label] (value)}
<button
type="button"
class={settings.font === value && 'on'}
onclick={() => updateSettings({ font: value })}>{label}</button
>
{/each}
</div>
</fieldset>
<fieldset>
<legend>Line spacing</legend>
<div class="seg">
{#each [1.3, 1.5, 1.75] as value (value)}
<button
type="button"
class={settings.lineHeight === value && 'on'}
onclick={() => updateSettings({ lineHeight: value })}
>{value === 1.3 ? 'Tight' : value === 1.5 ? 'Normal' : 'Loose'}</button
>
{/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.
</p>
</div>
{/if}
</div>
</aside>
{/if}
</div>
{#if selection || active}
<div class="sheet" role="dialog" aria-label={active ? 'Highlight' : 'Highlight selection'}>
{#if active}
<p class="sheet-quote">{active.highlight}</p>
<div class="flex items-center gap-2">
{#each highlightColors as c (c)}
<button
type="button"
class={['dot', active.color === c && 'on']}
style:background={highlightCss[c]}
aria-label={`Colour ${c}`}
onclick={() => patchHighlight(active, { color: c })}
></button>
{/each}
<span class="flex-1"></span>
<button
type="button"
class="icon-btn"
aria-label="Delete highlight"
onclick={() => removeHighlight(active)}><Trash2 size={16} /></button
>
<button
type="button"
class="icon-btn"
aria-label="Close"
onclick={() => (activeId = null)}><X size={16} /></button
>
</div>
<textarea
class="sheet-note"
rows="2"
placeholder="Add a note…"
bind:value={noteDraft}
onblur={() => {
if ((active.note ?? '') !== noteDraft.trim())
void patchHighlight(active, { note: noteDraft.trim() || null });
}}></textarea>
{:else if selection}
<div class="flex items-center gap-2">
{#each highlightColors as c (c)}
<button
type="button"
class="dot"
style:background={highlightCss[c]}
aria-label={`Highlight ${c}`}
title={`Highlight ${c}`}
onclick={() => createHighlight(c, noteFor === 'selection' ? noteDraft.trim() : null)}
></button>
{/each}
<span class="flex-1"></span>
<button
type="button"
class={['icon-btn', noteFor === 'selection' && 'on']}
aria-label="Highlight with a note"
onclick={() => {
noteFor = noteFor === 'selection' ? null : 'selection';
noteDraft = '';
}}><MessageSquarePlus size={16} /></button
>
<button
type="button"
class="icon-btn"
aria-label="Copy"
onclick={() => {
void navigator.clipboard?.writeText(selection?.text ?? '');
engine?.clearSelection();
selection = null;
}}><Copy size={16} /></button
>
</div>
{#if noteFor === 'selection'}
<!-- svelte-ignore a11y_autofocus -->
<textarea
class="sheet-note"
rows="2"
placeholder="Note, then pick a colour"
autofocus
bind:value={noteDraft}></textarea>
{/if}
{/if}
</div>
{/if}
<footer class="bar bottom">
<span class="w-12 text-xs tabular-nums opacity-70">{fmtPercent(percent)}</span>
<input
class="scrub"
type="range"
min="0"
max="100"
step="0.1"
value={percent}
aria-label="Position in book"
style:--fill={`${percent}%`}
oninput={(e) => (scrubbing = Number(e.currentTarget.value))}
onchange={async (e) => {
await engine?.goToPercent(Number(e.currentTarget.value));
scrubbing = null;
}}
/>
<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
saved{:else if saveState === 'saved'}saved{/if}
</span>
</footer>
</div>
<style>
.reader {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
flex-direction: column;
background: var(--r-bg);
color: var(--r-fg);
--r-line: color-mix(in srgb, var(--r-fg) 12%, transparent);
--r-soft: color-mix(in srgb, var(--r-fg) 7%, transparent);
}
.bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.75rem;
flex: none;
}
.top {
height: 3.25rem;
border-bottom: 1px solid var(--r-line);
}
.bottom {
height: 3rem;
border-top: 1px solid var(--r-line);
padding-bottom: env(safe-area-inset-bottom);
}
.icon-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
opacity: 0.75;
transition:
background 0.15s,
opacity 0.15s;
}
.icon-btn:hover,
.icon-btn.on {
opacity: 1;
background: var(--r-soft);
}
.count {
position: absolute;
top: 0.1rem;
right: 0.05rem;
font-size: 0.6rem;
min-width: 1rem;
line-height: 1rem;
border-radius: 999px;
background: var(--r-fg);
color: var(--r-bg);
font-variant-numeric: tabular-nums;
}
.stage {
position: relative;
flex: 1;
display: flex;
min-height: 0;
}
.book {
position: relative;
flex: 1;
min-width: 0;
}
.turn {
position: absolute;
top: 50%;
translate: 0 -50%;
z-index: 2;
width: 2.5rem;
height: 5rem;
display: none;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
opacity: 0.25;
transition: opacity 0.15s;
}
.turn:hover {
opacity: 0.8;
background: var(--r-soft);
}
.turn.left {
left: 0.5rem;
}
.turn.right {
right: 0.5rem;
}
@media (min-width: 768px) {
.turn {
display: flex;
}
}
.overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
background: var(--r-bg);
padding: 2rem;
}
.loadbar {
margin-top: 0.75rem;
width: 12rem;
height: 3px;
border-radius: 999px;
background: var(--r-line);
overflow: hidden;
}
.loadbar > span {
display: block;
height: 100%;
background: var(--r-fg);
transition: width 0.2s;
}
.panel {
width: 22rem;
max-width: 100%;
flex: none;
display: flex;
flex-direction: column;
border-left: 1px solid var(--r-line);
background: var(--r-bg);
}
@media (max-width: 900px) {
.panel {
position: absolute;
inset: 0 0 0 auto;
z-index: 5;
box-shadow: -12px 0 32px rgb(0 0 0 / 0.2);
}
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.75rem 0.6rem 1rem;
border-bottom: 1px solid var(--r-line);
}
.panel-head h2 {
font-size: 0.9rem;
font-weight: 600;
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
}
.empty {
font-size: 0.85rem;
opacity: 0.65;
padding: 0.5rem;
}
.toc-item {
display: block;
width: 100%;
text-align: left;
font-size: 0.875rem;
padding-block: 0.4rem;
padding-right: 0.5rem;
border-radius: 0.375rem;
opacity: 0.85;
}
.toc-item:hover {
background: var(--r-soft);
opacity: 1;
}
.toc-item.current {
font-weight: 600;
opacity: 1;
}
.hl-item {
display: block;
width: 100%;
text-align: left;
padding: 0.6rem 0.75rem;
border-radius: 0.5rem;
border-left: 3px solid var(--hl);
background: color-mix(in srgb, var(--hl) 10%, transparent);
}
.hl-item:hover {
background: color-mix(in srgb, var(--hl) 18%, transparent);
}
.hl-item .quote {
display: -webkit-box;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
font-family: Georgia, serif;
font-size: 0.875rem;
line-height: 1.5;
}
.hl-item .note {
display: block;
margin-top: 0.35rem;
font-size: 0.8rem;
opacity: 0.8;
}
.hl-item .meta {
display: block;
margin-top: 0.25rem;
font-size: 0.7rem;
opacity: 0.55;
}
fieldset legend {
font-size: 0.75rem;
opacity: 0.65;
margin-bottom: 0.4rem;
}
.themes {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.4rem;
}
.theme-swatch {
height: 2.75rem;
border-radius: 0.5rem;
font-size: 0.75rem;
border: 1px solid var(--r-line);
text-transform: capitalize;
}
.theme-swatch.on {
outline: 2px solid var(--r-fg);
outline-offset: 2px;
}
.stepper,
.seg {
display: flex;
align-items: center;
border: 1px solid var(--r-line);
border-radius: 0.5rem;
overflow: hidden;
}
.stepper button {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.25rem;
}
.stepper button:hover,
.seg button:hover {
background: var(--r-soft);
}
.stepper span {
flex: 1;
text-align: center;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.seg button {
flex: 1;
height: 2.25rem;
font-size: 0.8rem;
}
.seg button + button {
border-left: 1px solid var(--r-line);
}
.seg button.on {
background: var(--r-fg);
color: var(--r-bg);
}
.sheet {
position: absolute;
left: 50%;
bottom: 3.75rem;
translate: -50% 0;
z-index: 10;
width: min(26rem, calc(100% - 1.5rem));
padding: 0.6rem 0.75rem;
border-radius: 0.875rem;
border: 1px solid var(--r-line);
background: color-mix(in srgb, var(--r-bg) 92%, var(--r-fg));
box-shadow: 0 12px 40px rgb(0 0 0 / 0.25);
animation: rise 0.15s ease-out;
}
@keyframes rise {
from {
opacity: 0;
translate: -50% 6px;
}
}
.sheet-quote {
font-family: Georgia, serif;
font-size: 0.875rem;
line-height: 1.5;
max-height: 6rem;
overflow-y: auto;
margin-bottom: 0.5rem;
}
.sheet-note {
margin-top: 0.5rem;
width: 100%;
resize: vertical;
border-radius: 0.5rem;
border: 1px solid var(--r-line);
background: transparent;
padding: 0.4rem 0.6rem;
font-size: 0.875rem;
color: inherit;
}
.dot {
width: 1.6rem;
height: 1.6rem;
border-radius: 999px;
border: 2px solid transparent;
transition: transform 0.1s;
}
.dot:hover {
transform: scale(1.12);
}
.dot.on {
border-color: var(--r-fg);
}
.scrub {
flex: 1;
appearance: none;
height: 4px;
border-radius: 999px;
background: linear-gradient(
90deg,
var(--r-fg) var(--fill),
color-mix(in srgb, var(--r-fg) 18%, transparent) var(--fill)
);
cursor: pointer;
}
.scrub::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
border-radius: 999px;
background: var(--r-fg);
}
.scrub::-moz-range-thumb {
width: 14px;
height: 14px;
border: 0;
border-radius: 999px;
background: var(--r-fg);
}
</style>

View File

@@ -0,0 +1,157 @@
// Auth gating for the book tracker: files, reader, progress and notes are owner-only and
// answer 404 to everyone else; the public pages never leak files or notes.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
vi.mock('#lib/server/db/index.js', () => ({ db: {} }));
vi.mock('#lib/server/books/library.js', () => ({
coverSrc: vi.fn(() => null),
getWork: vi.fn(),
workFiles: vi.fn(async () => []),
workNotes: vi.fn(async () => []),
workSessions: vi.fn(async () => []),
saveProgress: vi.fn(async () => ({ sessionId: 7 })),
setStatus: vi.fn(),
setRating: vi.fn(),
setManualProgress: vi.fn(),
logManualSession: vi.fn(),
libraryCards: vi.fn(async () => []),
libraryStats: vi.fn(async () => ({}))
}));
vi.mock('#lib/server/books/notes.js', async (original) => ({
...(await original<typeof import('#lib/server/books/notes.js')>()),
createNote: vi.fn(async () => ({ id: 1 })),
updateNote: vi.fn(async () => ({ id: 1 })),
deleteNote: vi.fn(async () => true)
}));
import * as library from '#lib/server/books/library.js';
import * as notes from '#lib/server/books/notes.js';
import { POST as progress } from './[id=integer]/progress/+server';
import { POST as createNote } from './[id=integer]/notes/+server';
import {
PATCH as patchNote,
DELETE as deleteNote
} from './[id=integer]/notes/[noteId=integer]/+server';
import { GET as file } from './files/[id=integer]/+server';
import { load as readerLoad } from './[id=integer]/read/+page.server';
import { actions, load as detailLoad } from './[id=integer]/+page.server';
const OWNER = { id: 'u1', email: 'stan@stanrunge.dev', role: 'admin' };
const STRANGER = { id: 'u2', email: 'someone@example.com', role: 'admin' };
function event(user: object | null, init: RequestInit = {}, params: Record<string, string> = {}) {
return {
locals: { user, session: null },
params: { id: '1', noteId: '1', ...params },
request: new Request('https://site.test/trackers/books/1', init),
url: new URL('https://site.test/trackers/books/1'),
setHeaders: vi.fn()
} as never;
}
async function status(fn: () => unknown) {
try {
const result = await fn();
return result instanceof Response ? result.status : 200;
} catch (e) {
if (isHttpError(e)) return e.status;
throw e;
}
}
const json = (body: unknown, method = 'POST') => ({
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
const work = {
id: 1,
title: 'Dune',
subtitle: null,
authors: ['Frank Herbert'],
year: 1965,
publisher: null,
isbn13: null,
isbn10: null,
language: 'en',
pageCount: 412,
description: null,
tags: [],
coverPath: null,
coverUrl: null,
openlibraryWorkId: null
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(library.getWork).mockResolvedValue({ work, progress: null } as never);
});
describe.each([
['anonymous', null],
['another signed-in user', STRANGER]
])('%s', (_, user) => {
it('gets 404 from every owner-only endpoint', async () => {
expect(await status(() => progress(event(user, json({ percent: 10 }))))).toBe(404);
expect(await status(() => createNote(event(user, json({ note: 'hi' }))))).toBe(404);
expect(await status(() => patchNote(event(user, json({ note: 'x' }, 'PATCH'))))).toBe(404);
expect(await status(() => deleteNote(event(user, { method: 'DELETE' })))).toBe(404);
expect(await status(() => file(event(user)))).toBe(404);
expect(await status(() => readerLoad(event(user)))).toBe(404);
for (const action of Object.values(actions))
expect(
await status(() => action(event(user, { method: 'POST', body: new FormData() })))
).toBe(404);
expect(library.saveProgress).not.toHaveBeenCalled();
expect(notes.createNote).not.toHaveBeenCalled();
expect(library.setStatus).not.toHaveBeenCalled();
});
it('sees the public catalogue page without files, notes or sessions', async () => {
const data = (await detailLoad(event(user))) as Record<string, unknown>;
expect(data.owner).toBe(false);
expect(data).not.toHaveProperty('files');
expect(data).not.toHaveProperty('notes');
expect(data).not.toHaveProperty('sessions');
expect(library.workNotes).not.toHaveBeenCalled();
});
});
describe('owner', () => {
it('saves progress and gets a session id back', async () => {
const res = await progress(
event(OWNER, json({ fileId: 2, locator: 'page:5', page: 5, percent: 1.234 }))
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ sessionId: 7 });
expect(library.saveProgress).toHaveBeenCalledWith(
1,
expect.objectContaining({ percent: 1.23, page: 5 })
);
});
it('rejects invalid progress', async () => {
expect(await status(() => progress(event(OWNER, json({ percent: 140 }))))).toBe(400);
expect(await status(() => progress(event(OWNER, { method: 'POST', body: '{' })))).toBe(400);
expect(library.saveProgress).not.toHaveBeenCalled();
});
it('404s progress for a book that does not exist', async () => {
vi.mocked(library.saveProgress).mockResolvedValueOnce(null);
expect(await status(() => progress(event(OWNER, json({ percent: 3 }))))).toBe(404);
});
it('creates highlights and refuses empty notes', async () => {
expect(
await status(() => createNote(event(OWNER, json({ highlight: 'quote', locator: 'page:1' }))))
).toBe(201);
expect(await status(() => createNote(event(OWNER, json({ note: ' ' }))))).toBe(400);
});
it('sees notes, files and sessions on the detail page', async () => {
const data = (await detailLoad(event(OWNER))) as Record<string, unknown>;
expect(data.owner).toBe(true);
expect(data).toHaveProperty('notes');
expect(data).toHaveProperty('files');
});
it('gets 404 from the reader when the book has no readable file', async () => {
expect(await status(() => readerLoad(event(OWNER)))).toBe(404);
});
});

View File

@@ -0,0 +1,23 @@
// Public cover images for the catalogue (covers only, never book files).
import { error } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { bookWorks } from '#lib/server/db/schema/index.js';
import { safeResolve, streamFile } from '#lib/server/books/files.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async (event) => {
const [work] = await db
.select({ coverPath: bookWorks.coverPath })
.from(bookWorks)
.where(eq(bookWorks.id, Number(event.params.id)))
.limit(1);
const resolved = work?.coverPath ? await safeResolve('covers', work.coverPath) : null;
if (!resolved) error(404, 'Not Found');
const response = await streamFile(resolved, event.request, {
'cache-control': 'public, max-age=86400, stale-while-revalidate=604800',
'x-content-type-options': 'nosniff'
});
if (!response.headers.get('content-type')?.startsWith('image/')) error(404, 'Not Found');
return response;
};

View File

@@ -0,0 +1,30 @@
// Owner-only book file download/stream for the reader. Everyone else gets 404, the same as
// a file that doesn't exist. Files are read from BOOKS_ROOT/library, never from static/.
import { error } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { bookFiles } from '#lib/server/db/schema/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { safeResolve, streamFile } from '#lib/server/books/files.js';
import type { RequestHandler } from './$types';
const handler: RequestHandler = async (event) => {
requireOwner(event);
const [file] = await db
.select({ path: bookFiles.path })
.from(bookFiles)
.where(eq(bookFiles.id, Number(event.params.id)))
.limit(1);
const resolved = file ? await safeResolve('library', file.path) : null;
if (!resolved) error(404, 'Not Found');
const name = resolved.split('/').at(-1) ?? 'book';
return streamFile(resolved, event.request, {
'cache-control': 'private, no-store',
'x-robots-tag': 'noindex, nofollow, noarchive',
'x-content-type-options': 'nosniff',
'content-disposition': `inline; filename*=UTF-8''${encodeURIComponent(name)}`
});
};
export const GET = handler;
export const HEAD = handler;

View File

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

View File

@@ -0,0 +1,110 @@
import { db } from '#lib/server/db/index.js';
import {
healthAppointments,
healthBodyMeasurements,
healthMedications,
healthNutritionDaily,
healthSleep,
healthVaccinations,
healthWorkouts
} from '#lib/server/db/schema/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { cleanRow, drizzleTables, TIME_ZONE, writeRows } from '#lib/server/health-import.js';
import { healthTables, isHealthTable } from '#lib/health/tables.js';
import { asc, desc, eq, gte, lt } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function today(): string {
return new Intl.DateTimeFormat('en-CA', { timeZone: TIME_ZONE }).format(new Date());
}
function daysAgo(day: string, n: number): string {
const d = new Date(`${day}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() - n);
return d.toISOString().slice(0, 10);
}
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const now = today();
const [measurements, workouts, nutrition, sleep, medications, vaccinations, upcoming, past] =
await Promise.all([
db
.select()
.from(healthBodyMeasurements)
.orderBy(asc(healthBodyMeasurements.date), asc(healthBodyMeasurements.createdAt)),
db
.select()
.from(healthWorkouts)
.orderBy(desc(healthWorkouts.date), asc(healthWorkouts.createdAt))
.limit(30),
db
.select()
.from(healthNutritionDaily)
.where(gte(healthNutritionDaily.date, daysAgo(now, 6)))
.orderBy(desc(healthNutritionDaily.date)),
db
.select()
.from(healthSleep)
.where(gte(healthSleep.date, daysAgo(now, 6)))
.orderBy(desc(healthSleep.date)),
db.select().from(healthMedications).orderBy(asc(healthMedications.name)),
db
.select()
.from(healthVaccinations)
.orderBy(desc(healthVaccinations.date), asc(healthVaccinations.vaccine)),
db
.select()
.from(healthAppointments)
.where(gte(healthAppointments.date, now))
.orderBy(asc(healthAppointments.date)),
db
.select()
.from(healthAppointments)
.where(lt(healthAppointments.date, now))
.orderBy(desc(healthAppointments.date))
.limit(10)
]);
return {
today: now,
measurements,
workouts,
nutrition,
sleep,
medications,
vaccinations,
appointments: { upcoming, past },
seo: { title: 'Health', description: 'Private health log.', index: false }
};
};
export const actions: Actions = {
save: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const table = form.get('table');
if (!isHealthTable(table)) return fail(400, { message: 'Unknown table' });
const row: Record<string, unknown> = { id: form.get('id') || undefined };
for (const field of healthTables[table].fields) row[field.key] = form.get(field.key) ?? '';
const result = cleanRow(table, row);
if (!result.ok) return fail(400, { message: result.error, table });
try {
await writeRows(db, { [table]: [result.row] });
} catch (e) {
return fail(400, { message: e instanceof Error ? e.message : 'Could not save', table });
}
return { saved: table };
},
delete: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const table = form.get('table');
const id = String(form.get('id') ?? '');
if (!isHealthTable(table) || !UUID.test(id)) return fail(400, { message: 'Invalid entry' });
const target = drizzleTables[table];
await db.delete(target).where(eq(target.id, id));
return { deleted: table };
}
};

View File

@@ -0,0 +1,486 @@
<script lang="ts">
import { enhance } from '$app/forms';
import BodyChart from '#lib/health/BodyChart.svelte';
import {
healthTables,
healthTableNames,
type Field,
type HealthTable
} from '#lib/health/tables.js';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import * as Dialog from '#lib/components/ui/dialog/index.js';
import Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil';
import Trash2 from 'lucide-svelte/icons/trash-2';
import Lock from 'lucide-svelte/icons/lock';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
type AnyRow = { id: string } & Record<string, unknown>;
let dialogOpen = $state(false);
let editor = $state<{ table: HealthTable; row: AnyRow | null; confirmDelete: boolean }>({
table: 'body_measurements',
row: null,
confirmDelete: false
});
let error = $state<string | null>(null);
const fields = $derived<readonly Field[]>(healthTables[editor.table].fields);
const latest = $derived(data.measurements.at(-1));
const latestWeight = $derived(data.measurements.findLast((m) => m.weightKg !== null));
const latestWaist = $derived(data.measurements.findLast((m) => m.waistCm !== null));
const todayNutrition = $derived(data.nutrition.find((n) => n.date === data.today));
const activeMeds = $derived(
data.medications.filter(
(m) =>
(!m.startDate || m.startDate <= data.today) && (!m.stopDate || m.stopDate >= data.today)
)
);
const otherMeds = $derived(data.medications.filter((m) => !activeMeds.includes(m)));
const sleepAverage = $derived.by(() => {
const hours = data.sleep.map((s) => s.hours).filter((h): h is number => h !== null);
return hours.length ? hours.reduce((a, b) => a + b, 0) / hours.length : null;
});
const workoutDays = $derived.by(() => {
const days: [string, typeof data.workouts][] = [];
for (const w of data.workouts) {
const last = days.at(-1);
if (last?.[0] === w.date) last[1].push(w);
else days.push([w.date, [w]]);
}
return days;
});
const camel = (key: string) => key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
const localTime = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Amsterdam',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const clock = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Amsterdam',
hour: '2-digit',
minute: '2-digit'
});
const dayLabel = (day: string) =>
new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: day.slice(0, 4) === data.today.slice(0, 4) ? undefined : 'numeric'
});
const num = (value: number | null | undefined, digits = 1) =>
value === null || value === undefined ? '–' : value.toFixed(digits).replace(/\.0+$/, '');
function fieldValue(field: Field): string {
const value = editor.row?.[camel(field.key)];
if (value === null || value === undefined) {
if (!editor.row && field.type === 'date' && field.key === 'date') return data.today;
return '';
}
if (value instanceof Date) return localTime.format(value).replace(' ', 'T');
return String(value);
}
function open(table: HealthTable, row: AnyRow | null = null, confirmDelete = false) {
editor = { table, row, confirmDelete };
error = null;
dialogOpen = true;
}
const submit: SubmitFunction =
() =>
async ({ result, update }) => {
await update();
if (result.type === 'success') dialogOpen = false;
else error = String((result.type === 'failure' && result.data?.message) || 'Could not save');
};
</script>
{#snippet rowActions(table: HealthTable, row: AnyRow)}
<span class="flex shrink-0 gap-1">
<button class="icon" type="button" aria-label="Edit" onclick={() => open(table, row)}
><Pencil size={14} /></button
>
<button class="icon" type="button" aria-label="Delete" onclick={() => open(table, row, true)}
><Trash2 size={14} /></button
>
</span>
{/snippet}
{#snippet sectionHeader(title: string, table: HealthTable)}
<header class="mb-3 flex items-center justify-between gap-2">
<h2 class="text-lg font-semibold tracking-tight">{title}</h2>
<button class="icon" type="button" aria-label={`Add to ${title}`} onclick={() => open(table)}
><Plus size={16} /></button
>
</header>
{/snippet}
{#snippet empty(text: string)}
<p class="text-sm text-[var(--site-muted)]">{text}</p>
{/snippet}
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">health</h1>
<p class="mt-2 flex items-center gap-1.5 text-sm text-[var(--site-muted)]">
<Lock size={14} aria-hidden="true" /> Private. Only you can see this page.
</p>
</div>
<NativeSelect
aria-label="Add an entry"
value=""
onchange={(e: Event) => {
const select = e.currentTarget as HTMLSelectElement;
if (select.value) open(select.value as HealthTable);
select.value = '';
}}
>
<option value="">+ add entry…</option>
{#each healthTableNames as table (table)}<option value={table}
>{healthTables[table].label}</option
>{/each}
</NativeSelect>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<section class="panel lg:col-span-2">
{@render sectionHeader('Body', 'body_measurements')}
{#if latest}
<div class="mb-3 flex flex-wrap gap-6">
<p>
<span class="stat">{num(latestWeight?.weightKg, 2)}</span> kg
<small>{latestWeight ? dayLabel(latestWeight.date) : ''}</small>
</p>
<p>
<span class="stat">{num(latestWaist?.waistCm)}</span> cm waist
<small>{latestWaist ? dayLabel(latestWaist.date) : ''}</small>
</p>
{#if latest.bodyFatPct !== null}<p>
<span class="stat">{num(latest.bodyFatPct)}</span>% fat
</p>{/if}
</div>
{#if data.measurements.length > 1}
<BodyChart points={data.measurements} />
{:else}
{@render empty('Add a second measurement to see a trend.')}
{/if}
<details class="mt-3 text-sm">
<summary class="cursor-pointer text-[var(--site-muted)]"
>All measurements ({data.measurements.length})</summary
>
<ul class="mt-2 divide-y divide-[var(--site-border)]">
{#each data.measurements.toReversed() as m (m.id)}
<li class="flex items-center justify-between py-1.5">
<span
>{dayLabel(m.date)} · {num(m.weightKg, 2)} kg · {num(m.waistCm)} cm{m.bodyFatPct !==
null
? ` · ${num(m.bodyFatPct)}%`
: ''}{m.notes ? ` · ${m.notes}` : ''}</span
>
{@render rowActions('body_measurements', m)}
</li>
{/each}
</ul>
</details>
{:else}
{@render empty('No measurements yet.')}
{/if}
</section>
<section class="panel">
{@render sectionHeader('Nutrition today', 'nutrition_daily')}
{#if todayNutrition}
<p>
<span class="stat">{todayNutrition.kcal ?? '–'}</span> kcal ·
<span class="stat">{num(todayNutrition.proteinG)}</span> g protein
</p>
{:else}
{@render empty('Nothing logged today.')}
{/if}
{#if data.nutrition.length}
<ul class="mt-3 divide-y divide-[var(--site-border)] text-sm">
{#each data.nutrition as n (n.id)}
<li class="flex items-center justify-between py-1.5">
<span>{dayLabel(n.date)} · {n.kcal ?? '–'} kcal · {num(n.proteinG)} g</span>
{@render rowActions('nutrition_daily', n)}
</li>
{/each}
</ul>
{/if}
</section>
<section class="panel">
{@render sectionHeader('Sleep, last 7 days', 'sleep')}
{#if data.sleep.length}
{#if sleepAverage !== null}<p class="mb-2">
<span class="stat">{num(sleepAverage, 1)}</span> h average
</p>{/if}
<ul class="divide-y divide-[var(--site-border)] text-sm">
{#each data.sleep as s (s.id)}
<li class="flex items-center justify-between py-1.5">
<span
>{dayLabel(s.date)} · {clock.format(s.bedtime)}–{clock.format(s.wakeTime)} · {num(
s.hours,
2
)} h</span
>
{@render rowActions('sleep', s)}
</li>
{/each}
</ul>
{:else}
{@render empty('No sleep logged this week.')}
{/if}
</section>
<section class="panel">
{@render sectionHeader('Medications & supplements', 'medications')}
{#if activeMeds.length}
<ul class="divide-y divide-[var(--site-border)] text-sm">
{#each activeMeds as m (m.id)}
<li class="flex items-center justify-between gap-2 py-1.5">
<span
><strong>{m.name}</strong>{m.dose ? ` · ${m.dose}` : ''}{m.frequency
? ` · ${m.frequency}`
: ''}
<small>{m.kind}{m.startDate ? `, since ${dayLabel(m.startDate)}` : ''}</small></span
>
{@render rowActions('medications', m)}
</li>
{/each}
</ul>
{:else}
{@render empty('No active medications or supplements.')}
{/if}
{#if otherMeds.length}
<details class="mt-2 text-sm">
<summary class="cursor-pointer text-[var(--site-muted)]"
>Stopped or not started ({otherMeds.length})</summary
>
<ul class="mt-1">
{#each otherMeds as m (m.id)}
<li class="flex items-center justify-between py-1">
<span>{m.name} <small>{m.startDate ?? '?'} → {m.stopDate ?? 'now'}</small></span>
{@render rowActions('medications', m)}
</li>
{/each}
</ul>
</details>
{/if}
</section>
<section class="panel">
{@render sectionHeader('Appointments', 'appointments')}
{#if data.appointments.upcoming.length}
<ul class="divide-y divide-[var(--site-border)] text-sm">
{#each data.appointments.upcoming as a (a.id)}
<li class="flex items-center justify-between gap-2 py-1.5">
<span
><strong>{dayLabel(a.date)}</strong> · {a.type}{a.provider
? ` · ${a.provider}`
: ''}{a.notes ? ` · ${a.notes}` : ''}</span
>
{@render rowActions('appointments', a)}
</li>
{/each}
</ul>
{:else}
{@render empty('Nothing upcoming.')}
{/if}
{#if data.appointments.past.length}
<details class="mt-2 text-sm">
<summary class="cursor-pointer text-[var(--site-muted)]"
>Recent ({data.appointments.past.length})</summary
>
<ul class="mt-1">
{#each data.appointments.past as a (a.id)}
<li class="flex items-center justify-between py-1">
<span>{dayLabel(a.date)} · {a.type}{a.provider ? ` · ${a.provider}` : ''}</span>
{@render rowActions('appointments', a)}
</li>
{/each}
</ul>
</details>
{/if}
</section>
<section class="panel lg:col-span-2">
{@render sectionHeader('Recent workouts', 'workouts')}
{#if workoutDays.length}
{#each workoutDays as [day, entries] (day)}
<h3 class="mt-2 text-sm font-medium text-[var(--site-muted)]">{dayLabel(day)}</h3>
<ul class="divide-y divide-[var(--site-border)] text-sm">
{#each entries as w (w.id)}
<li class="flex items-center justify-between gap-2 py-1.5">
<span
><strong>{w.exercise}</strong>{w.sets
? ` · ${w.sets}×${w.reps ?? '?'}`
: ''}{w.loadKg !== null ? ` @ ${num(w.loadKg, 2)} kg` : ''}{w.rpe !== null
? ` · RPE ${num(w.rpe)}`
: ''}{w.notes ? ` · ${w.notes}` : ''}</span
>
{@render rowActions('workouts', w)}
</li>
{/each}
</ul>
{/each}
{:else}
{@render empty('No workouts yet.')}
{/if}
</section>
<section class="panel lg:col-span-3">
{@render sectionHeader('Vaccinations', 'vaccinations')}
{#if data.vaccinations.length}
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="text-[var(--site-muted)]">
<tr
><th>Date</th><th>Vaccine</th><th>Against</th><th>Dose</th><th>Batch</th><th
>Provider</th
><th>Country</th><th></th></tr
>
</thead>
<tbody>
{#each data.vaccinations as v (v.id)}
<tr class="border-t border-[var(--site-border)]">
<td>{v.date}</td><td>{v.vaccine}</td><td>{v.target ?? ''}</td><td
>{v.doseNo ?? ''}</td
>
<td>{v.batchLot ?? ''}</td><td>{v.provider ?? ''}</td><td>{v.country ?? ''}</td>
<td>{@render rowActions('vaccinations', v)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
{@render empty('No vaccinations yet. Import your Mijn RIVM record to fill this in.')}
{/if}
</section>
</div>
</div>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
<Dialog.Header class="mb-4 pr-8">
<Dialog.Title>
{editor.confirmDelete ? 'Delete entry' : editor.row ? 'Edit' : 'Add'} · {healthTables[
editor.table
].label}
</Dialog.Title>
<Dialog.Description class="sr-only">Manage a private health record.</Dialog.Description>
</Dialog.Header>
{#if editor.confirmDelete && editor.row}
<form method="POST" action="?/delete" use:enhance={submit}>
<input type="hidden" name="table" value={editor.table} />
<input type="hidden" name="id" value={editor.row.id} />
<p class="mb-4 text-sm">Delete this entry? This cannot be undone.</p>
{#if error}<p class="mb-3 text-sm text-red-500" role="alert">{error}</p>{/if}
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={() => (dialogOpen = false)}
>Cancel</Button
>
<Button type="submit" variant="destructive">Delete</Button>
</div>
</form>
{:else}
<form method="POST" action="?/save" use:enhance={submit} class="grid gap-3">
<input type="hidden" name="table" value={editor.table} />
{#if editor.row}<input type="hidden" name="id" value={editor.row.id} />{/if}
{#each fields as field (field.key)}
<label class="grid gap-1 text-sm">
<span>{field.label}{field.required ? ' *' : ''}</span>
{#if field.type === 'textarea'}
<Textarea
name={field.key}
value={fieldValue(field)}
maxlength={field.maxLength}
rows={2}
/>
{:else if field.type === 'select'}
<NativeSelect name={field.key} value={fieldValue(field) || field.options?.[0]}>
{#each field.options ?? [] as option (option)}<option value={option}
>{option}</option
>{/each}
</NativeSelect>
{:else}
<Input
name={field.key}
value={fieldValue(field)}
required={field.required}
type={field.type === 'date'
? 'date'
: field.type === 'datetime'
? 'datetime-local'
: field.type === 'integer' || field.type === 'number'
? 'number'
: 'text'}
min={field.min}
max={field.max}
step={field.type === 'integer' ? 1 : field.step}
maxlength={field.maxLength}
placeholder={field.placeholder}
inputmode={field.type === 'number' ? 'decimal' : undefined}
/>
{/if}
</label>
{/each}
{#if error}<p class="text-sm text-red-500" role="alert">{error}</p>{/if}
<div class="mt-2 flex justify-end gap-2">
<Button type="button" variant="outline" onclick={() => (dialogOpen = false)}
>Cancel</Button
>
<Button type="submit">Save</Button>
</div>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
{#if form?.message && !dialogOpen}<p class="sr-only" role="status">{form.message}</p>{/if}
<style>
.panel {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 1rem 1.25rem;
}
.stat {
font-size: 1.6rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
small {
color: var(--site-muted);
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
padding: 0.3rem;
color: var(--site-muted);
}
.icon:hover {
background: color-mix(in srgb, var(--site-muted) 15%, transparent);
color: var(--site-text);
}
th,
td {
padding: 0.4rem 0.5rem 0.4rem 0;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,51 @@
import { db } from '#lib/server/db/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { parseCsv, toPayload, validatePayload, writeRows } from '#lib/server/health-import.js';
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const MAX_BYTES = 2_000_000;
// Owner-only import. Same formats as scripts/health-import.ts:
// POST application/json {"vaccinations":[...], "sleep":[...]} or [...] with ?table=
// POST text/csv header row + rows, with ?table=
// ?dry_run=1 validate only
// Only JSON/CSV content types are accepted, so a cross-site form can't post here
// (browsers preflight these types, and we never send CORS headers).
export const POST: RequestHandler = async (event) => {
requireOwner(event);
const type = (event.request.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
if (type !== 'application/json' && type !== 'text/csv')
error(415, 'Use application/json or text/csv');
const text = await event.request.text();
if (text.length > MAX_BYTES) error(413, 'Body too large');
const table = event.url.searchParams.get('table');
let payload;
try {
payload =
type === 'text/csv' ? toPayload(parseCsv(text), table) : toPayload(JSON.parse(text), table);
} catch (e) {
return json(
{ ok: false, error: e instanceof Error ? e.message : 'Bad input' },
{ status: 400 }
);
}
const { clean, errors } = validatePayload(payload);
if (errors.length) return json({ ok: false, errors: errors.slice(0, 100) }, { status: 400 });
const rows = Object.fromEntries(Object.entries(clean).map(([t, r]) => [t, r.length]));
if (event.url.searchParams.get('dry_run') === '1') return json({ ok: true, dryRun: true, rows });
try {
return json({ ok: true, counts: await writeRows(db, clean) });
} catch (e) {
return json(
{ ok: false, error: e instanceof Error ? e.message : 'Import failed' },
{ status: 400 }
);
}
};
// Any other method: still 404 for non-owners (no 405 that would reveal the endpoint).
export const fallback: RequestHandler = (event) => {
requireOwner(event);
error(405, 'Use POST');
};

21
static/vendor/foliate-js/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 John Factotum
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1
static/vendor/foliate-js/VERSION vendored Normal file
View File

@@ -0,0 +1 @@
foliate-js @ 78914aef4466eb960965702401634c2cb348e9b1 (MIT), https://github.com/johnfactotum/foliate-js. vendor/pdfjs omitted: PDFs use pdfjs-dist from npm.

45
static/vendor/foliate-js/comic-book.js vendored Normal file
View File

@@ -0,0 +1,45 @@
export const makeComicBook = ({ entries, loadBlob, getSize }, file) => {
const cache = new Map()
const urls = new Map()
const load = async name => {
if (cache.has(name)) return cache.get(name)
const src = URL.createObjectURL(await loadBlob(name))
const page = URL.createObjectURL(
new Blob([`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin: 0"><img src="${src}"></body></html>`], { type: 'text/html' }))
urls.set(name, [src, page])
cache.set(name, page)
return page
}
const unload = name => {
urls.get(name)?.forEach?.(url => URL.revokeObjectURL(url))
urls.delete(name)
cache.delete(name)
}
const exts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.jxl', '.avif']
const files = entries
.map(entry => entry.filename)
.filter(name => exts.some(ext => name.endsWith(ext)))
.sort(new Intl.Collator([], { numeric: true }).compare)
if (!files.length) throw new Error('No supported image files in archive')
const book = {}
book.getCover = () => loadBlob(files[0])
book.metadata = { title: file.name }
book.sections = files.map(name => ({
id: name,
load: () => load(name),
unload: () => unload(name),
size: getSize(name),
}))
book.toc = files.map(name => ({ label: name, href: name }))
book.rendition = { layout: 'pre-paginated' }
book.resolveHref = href => ({ index: book.sections.findIndex(s => s.id === href) })
book.splitTOCHref = href => [href, null]
book.getTOCFragment = doc => doc.documentElement
book.destroy = () => {
for (const arr of urls.values())
for (const url of arr) URL.revokeObjectURL(url)
}
return book
}

Some files were not shown because too many files have changed in this diff Show More