Add private health tracker (owner-only)
- health schema: vaccinations, body_measurements, workouts, nutrition_daily, sleep (generated hours), medications, hair_progress, appointments - migration with updated_at triggers; grants to personal_app only - /trackers/health overview with add/edit/delete, owner-gated (404 otherwise), noindex + private no-store, hidden from listings for non-owners - owner-only JSON/CSV import endpoint and scripts/health-import.ts
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import type { Handle } from '@sveltejs/kit/hooks';
|
||||
import { building } from '$app/env';
|
||||
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 }) => {
|
||||
// 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 });
|
||||
event.locals.user = session?.user ?? 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 FolderKanban from 'lucide-svelte/icons/folder-kanban';
|
||||
import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
|
||||
import HeartPulse from 'lucide-svelte/icons/heart-pulse';
|
||||
import House from 'lucide-svelte/icons/house';
|
||||
import Globe2 from 'lucide-svelte/icons/globe-2';
|
||||
import Languages from 'lucide-svelte/icons/languages';
|
||||
@@ -34,7 +35,8 @@
|
||||
'money tracker': Wallet,
|
||||
'cs prices': CandlestickChart,
|
||||
'house tracker': House,
|
||||
lists: ListChecks
|
||||
lists: ListChecks,
|
||||
health: HeartPulse
|
||||
};
|
||||
|
||||
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>
|
||||
142
src/lib/health/tables.ts
Normal file
142
src/lib/health/tables.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// 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
|
||||
]
|
||||
},
|
||||
hair_progress: {
|
||||
label: 'Hair progress',
|
||||
fields: [
|
||||
date,
|
||||
{
|
||||
key: 'photo_ref',
|
||||
label: 'Photo key (private storage, not a URL)',
|
||||
type: 'text',
|
||||
maxLength: 500,
|
||||
placeholder: 'health/hair/2026-10-24-top.jpg'
|
||||
},
|
||||
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
|
||||
}
|
||||
] 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;
|
||||
}
|
||||
|
||||
199
src/lib/server/db/schema/health.ts
Normal file
199
src/lib/server/db/schema/health.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
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 healthHairProgress = healthSchema.table(
|
||||
'hair_progress',
|
||||
{
|
||||
id: pk(),
|
||||
date: day().notNull(),
|
||||
// Key/path into private storage (never a public URL), e.g. "health/hair/2026-10-24-top.jpg".
|
||||
photoRef: text('photo_ref'),
|
||||
notes: text('notes'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('hair_progress_date_idx').on(t.date),
|
||||
check(
|
||||
'hair_progress_photo_ref_check',
|
||||
sql`${t.photoRef} IS NULL OR (${t.photoRef} !~* '^[a-z][a-z0-9+.-]*://' AND ${t.photoRef} !~ '^//')`
|
||||
),
|
||||
check('hair_progress_date_check', sane('date'))
|
||||
]
|
||||
);
|
||||
|
||||
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 './cs-prices';
|
||||
export * from './house-tracker';
|
||||
export * from './health';
|
||||
|
||||
@@ -12,3 +12,4 @@ export const csSchema = pgSchema('cs');
|
||||
export const animaldexSchema = pgSchema('animaldex');
|
||||
export const siteSchema = pgSchema('site');
|
||||
export const metaSchema = pgSchema('meta');
|
||||
export const healthSchema = pgSchema('health');
|
||||
|
||||
55
src/lib/server/health-import.test.ts
Normal file
55
src/lib/server/health-import.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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('hair_progress', { date: '2026-01-02', photo_ref: 'https://x/y.jpg' }).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();
|
||||
});
|
||||
});
|
||||
357
src/lib/server/health-import.ts
Normal file
357
src/lib/server/health-import.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
// 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,
|
||||
healthHairProgress,
|
||||
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,
|
||||
hair_progress: healthHairProgress,
|
||||
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 === 'hair_progress' &&
|
||||
typeof v.photo_ref === 'string' &&
|
||||
/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(v.photo_ref)
|
||||
)
|
||||
return { ok: false, error: 'photo_ref must be a private storage key/path, not a URL' };
|
||||
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;
|
||||
});
|
||||
}
|
||||
21
src/lib/server/owner.test.ts
Normal file
21
src/lib/server/owner.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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('needs admin role and the owner email', () => {
|
||||
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(false);
|
||||
expect(isOwner(event({ role: 'admin', 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 one owner (Stan). Owner-only data, such as the health tracker, needs both
|
||||
// the admin role and the owner's account email, so granting someone else `admin` (e.g. to
|
||||
// moderate Animaldex) never exposes it. Override with OWNER_EMAIL if the address changes.
|
||||
const OWNER_EMAIL = (process.env.OWNER_EMAIL || 'stan@stanrunge.dev').toLowerCase();
|
||||
|
||||
type OwnerEvent = { locals: App.Locals };
|
||||
|
||||
export function isOwner(event: OwnerEvent): boolean {
|
||||
const user = event.locals.user;
|
||||
return !!user && user.role === 'admin' && user.email?.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 { isOwner } from '#lib/server/owner.js';
|
||||
import { dev } from '$app/env';
|
||||
import { siteEnvironment } from '#lib/title.js';
|
||||
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 }
|
||||
: null,
|
||||
isAdmin: isAdmin(event),
|
||||
isOwner: isOwner(event),
|
||||
editable: isAdmin(event),
|
||||
version: process.env.APP_VERSION || 'dev',
|
||||
environment
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { getSeo } from '#lib/seo.js';
|
||||
import CopyButton from '#lib/components/CopyButton.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 * as Sheet from '#lib/components/ui/sheet/index.js';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
@@ -42,13 +42,11 @@
|
||||
{ label: 'blog', href: '/blog' },
|
||||
{ label: 'lists', href: '/lists' }
|
||||
] as const;
|
||||
const menus: Record<
|
||||
string,
|
||||
{ label: string; items: readonly (typeof projects | typeof trackers)[number][] }
|
||||
> = {
|
||||
type MenuItem = { name: string; subtitle: string; href: string; external: boolean };
|
||||
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
|
||||
'/projects': { label: 'projects', items: projects },
|
||||
'/trackers': { label: 'trackers', items: trackers }
|
||||
};
|
||||
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) }
|
||||
});
|
||||
const socialLinks = [
|
||||
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
|
||||
{ icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' },
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import portrait from '#lib/assets/stan.jpg?url';
|
||||
import ProgressBar from '#lib/components/ProgressBar.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 ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import Mail from 'lucide-svelte/icons/mail';
|
||||
@@ -114,7 +114,10 @@
|
||||
>trackers <ArrowRight size={20} aria-hidden="true" /></a
|
||||
>
|
||||
</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 aria-labelledby="lists-heading" class="pt-14 sm:pt-20">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import CatalogGrid from '#lib/components/CatalogGrid.svelte';
|
||||
import { trackers } from '#lib/projects.js';
|
||||
import { visibleTrackers } from '#lib/projects.js';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<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)]">
|
||||
Personal data I keep track of: games, money, houses, prices, animals and lists.
|
||||
</p>
|
||||
<CatalogGrid items={trackers} />
|
||||
<CatalogGrid items={visibleTrackers(data.isOwner)} />
|
||||
</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 {};
|
||||
};
|
||||
122
src/routes/trackers/health/+page.server.ts
Normal file
122
src/routes/trackers/health/+page.server.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import {
|
||||
healthAppointments,
|
||||
healthBodyMeasurements,
|
||||
healthHairProgress,
|
||||
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,
|
||||
hair
|
||||
] = 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),
|
||||
db.select().from(healthHairProgress).orderBy(desc(healthHairProgress.date)).limit(10)
|
||||
]);
|
||||
return {
|
||||
today: now,
|
||||
measurements,
|
||||
workouts,
|
||||
nutrition,
|
||||
sleep,
|
||||
medications,
|
||||
vaccinations,
|
||||
appointments: { upcoming, past },
|
||||
hair,
|
||||
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 };
|
||||
}
|
||||
};
|
||||
506
src/routes/trackers/health/+page.svelte
Normal file
506
src/routes/trackers/health/+page.svelte
Normal file
@@ -0,0 +1,506 @@
|
||||
<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">
|
||||
{@render sectionHeader('Hair progress', 'hair_progress')}
|
||||
{#if data.hair.length}
|
||||
<ul class="divide-y divide-[var(--site-border)] text-sm">
|
||||
{#each data.hair as h (h.id)}
|
||||
<li class="flex items-center justify-between gap-2 py-1.5">
|
||||
<span
|
||||
>{dayLabel(h.date)}{h.photoRef ? ` · ${h.photoRef}` : ''}{h.notes
|
||||
? ` · ${h.notes}`
|
||||
: ''}</span
|
||||
>
|
||||
{@render rowActions('hair_progress', h)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
{@render empty('No hair check-ins 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