16 Commits

Author SHA1 Message Date
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
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
29 changed files with 13469 additions and 44 deletions

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

@@ -378,7 +378,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

@@ -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,18 @@ 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'
],
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

@@ -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

@@ -16,8 +16,15 @@ 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"
# 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

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 } 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,22 @@ 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). Page loads, __data.json and the import endpoint
// 404 for non-owners in the route itself (layout load / endpoint), which renders the
// same 404 page as an unknown URL. As a backstop, any other method (form actions,
// which skip layout loads) is refused here before a route runs.
const ownerOnly = isOwnerOnlyPath(event.url.pathname);
if (
ownerOnly &&
!isOwner(event) &&
event.request.method !== 'GET' &&
event.request.method !== 'HEAD'
)
error(404, 'Not Found');
const response = await resolve(event);
if (ownerOnly) {
response.headers.set('x-robots-tag', 'noindex, nofollow, noarchive');
response.headers.set('cache-control', 'private, no-store');
}
return response;
}; };

View File

@@ -12,6 +12,7 @@
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 +35,8 @@
'money tracker': Wallet, 'money tracker': Wallet,
'cs prices': CandlestickChart, 'cs prices': CandlestickChart,
'house tracker': House, 'house tracker': House,
lists: ListChecks lists: ListChecks,
health: HeartPulse
}; };
const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban); const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban);

View File

@@ -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

@@ -71,3 +71,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

@@ -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,4 @@ export * from './schemas';
export * from './money-tracker'; export * from './money-tracker';
export * from './cs-prices'; export * from './cs-prices';
export * from './house-tracker'; export * from './house-tracker';
export * from './health';

View File

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

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

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { isOwner, isOwnerOnlyPath } from './owner';
const event = (user: Partial<NonNullable<App.Locals['user']>> | null) =>
({ locals: { user, session: null } }) as unknown as { locals: App.Locals };
describe('owner gate', () => {
it('is only the owner account', () => {
expect(isOwner(event(null))).toBe(false);
expect(isOwner(event({ role: 'admin', email: 'someone@example.com' }))).toBe(false);
expect(isOwner(event({ role: 'user', email: 'Stan@StanRunge.dev' }))).toBe(true);
});
it('matches owner-only paths', () => {
expect(isOwnerOnlyPath('/trackers/health')).toBe(true);
expect(isOwnerOnlyPath('/trackers/health/')).toBe(true);
expect(isOwnerOnlyPath('/trackers/Health/import')).toBe(true);
expect(isOwnerOnlyPath('/trackers/healthy')).toBe(false);
expect(isOwnerOnlyPath('/trackers')).toBe(false);
});
});

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

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

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';
@@ -42,13 +42,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' },
@@ -374,7 +372,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

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

@@ -1,6 +1,8 @@
<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">
@@ -8,5 +10,5 @@
<p class="mb-10 text-[var(--site-muted)]"> <p class="mb-10 text-[var(--site-muted)]">
Personal data I keep track of: games, money, houses, prices, animals and lists. Personal data I keep track of: games, money, houses, prices, animals and lists.
</p> </p>
<CatalogGrid items={trackers} /> <CatalogGrid items={visibleTrackers(data.isOwner)} />
</div> </div>

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');
};