Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8aa689af64 | |||
| 8275f1fb22 |
@@ -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,
|
||||||
|
|||||||
148
drizzle/20261024002120_health/migration.sql
Normal file
148
drizzle/20261024002120_health/migration.sql
Normal 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 $$;
|
||||||
11583
drizzle/20261024002120_health/snapshot.json
Normal file
11583
drizzle/20261024002120_health/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
72
scripts/health-import.ts
Normal file
72
scripts/health-import.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
118
src/lib/health/BodyChart.svelte
Normal file
118
src/lib/health/BodyChart.svelte
Normal 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
129
src/lib/health/tables.ts
Normal 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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
179
src/lib/server/db/schema/health.ts
Normal file
179
src/lib/server/db/schema/health.ts
Normal 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'))
|
||||||
|
]
|
||||||
|
);
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
52
src/lib/server/health-import.test.ts
Normal file
52
src/lib/server/health-import.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
349
src/lib/server/health-import.ts
Normal file
349
src/lib/server/health-import.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
20
src/lib/server/owner.test.ts
Normal file
20
src/lib/server/owner.test.ts
Normal 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
27
src/lib/server/owner.ts
Normal 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}/`));
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
11
src/routes/trackers/health/+layout.server.ts
Normal file
11
src/routes/trackers/health/+layout.server.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { requireOwner } from '#lib/server/owner.js';
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
// Owner only: everyone else (logged out, or any other account) gets a plain 404.
|
||||||
|
// Form actions don't run this load, so +page.server.ts checks again in every action,
|
||||||
|
// and hooks.server.ts blocks the whole /trackers/health prefix as a backstop.
|
||||||
|
export const load: LayoutServerLoad = (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||||
|
return {};
|
||||||
|
};
|
||||||
110
src/routes/trackers/health/+page.server.ts
Normal file
110
src/routes/trackers/health/+page.server.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
486
src/routes/trackers/health/+page.svelte
Normal file
486
src/routes/trackers/health/+page.svelte
Normal 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>
|
||||||
51
src/routes/trackers/health/import/+server.ts
Normal file
51
src/routes/trackers/health/import/+server.ts
Normal 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');
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user