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:
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>
|
||||
Reference in New Issue
Block a user