Files
website/src/routes/trackers/health/+page.svelte
Stan Runge 2bd668bf5e
All checks were successful
Deploy production / deploy (push) Successful in 2m39s
start: private new-tab dashboard at /start (tasks, notifications, backups/timers, server + containers, service status, trackers, editable links, / search)
- home schema: home.links (seeded, owner-editable) and home.snapshots (written by a host collector)
- deploy/home-collector: stdlib Python + systemd user timer (60s) that snapshots /proc stats,
  docker containers, systemd timers, service HTTP checks and ntfy topics via a limited role
- extras/newtab-extension: MV3 new-tab override, zipped to static/newtab-extension.zip
- owner-only pages skip Google Analytics; /trackers/health#body anchor for weigh-ins
2026-10-24 15:21:29 +00:00

599 lines
20 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { enhance } from '$app/forms';
import BodyChart from '#lib/health/BodyChart.svelte';
import {
healthTables,
healthTableNames,
scaleFields,
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;
}
// Quick-add for a scale reading: same ?/save action and dedupe as imports.
const nowLocal = () => localTime.format(new Date()).replace(' ', 'T');
let quickNow = $state(nowLocal());
let quickMessage = $state<{ text: string; error: boolean } | null>(null);
const quickSubmit: SubmitFunction =
() =>
async ({ result, update }) => {
if (result.type === 'success') {
quickNow = nowLocal();
await update();
quickMessage = {
text: result.data?.unchanged ? 'Already logged, nothing changed.' : 'Saved.',
error: false
};
} else {
await update({ reset: false });
quickMessage = {
text: String((result.type === 'failure' && result.data?.message) || 'Could not save'),
error: true
};
}
};
const scaleSummary = (m: (typeof data.measurements)[number]) =>
[
m.muscleMassKg !== null ? `muscle ${num(m.muscleMassKg, 2)} kg` : '',
m.visceralFat !== null ? `visceral ${num(m.visceralFat)}` : '',
m.bodyWaterPct !== null ? `water ${num(m.bodyWaterPct)}%` : '',
m.bmi !== null ? `BMI ${num(m.bmi)}` : ''
]
.filter(Boolean)
.join(' · ');
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 id="body" class="panel lg:col-span-2">
{@render sectionHeader('Body', 'body_measurements')}
<form method="POST" action="?/save" use:enhance={quickSubmit} class="quick mb-4 grid gap-2">
<input type="hidden" name="table" value="body_measurements" />
<div class="flex flex-wrap items-end gap-2">
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
When
<Input
type="datetime-local"
name="measured_at"
value={quickNow}
required
class="w-52"
/>
</label>
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
Weight (kg)
<Input
type="number"
name="weight_kg"
min={20}
max={400}
step={0.01}
inputmode="decimal"
class="w-24"
/>
</label>
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
Body fat (%)
<Input
type="number"
name="body_fat_pct"
min={1}
max={75}
step={0.1}
inputmode="decimal"
class="w-24"
/>
</label>
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
Source
<Input name="source" value="Renpho" maxlength={100} class="w-28" />
</label>
<Button type="submit">Add</Button>
</div>
<details class="text-sm">
<summary class="cursor-pointer text-[var(--site-muted)]">More readings & notes</summary>
<div class="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
{#each scaleFields as field (field.key)}
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
{field.label}
<Input
type="number"
name={field.key}
min={field.min}
max={field.max}
step={field.type === 'integer' ? 1 : field.step}
inputmode="decimal"
/>
</label>
{/each}
</div>
<label class="mt-2 grid gap-1 text-xs text-[var(--site-muted)]">
Notes
<Textarea name="notes" rows={2} maxlength={4000} />
</label>
</details>
{#if quickMessage}
<p
class={['text-sm', quickMessage.error ? 'text-red-500' : 'text-[var(--site-muted)]']}
role={quickMessage.error ? 'alert' : 'status'}
>
{quickMessage.text}
</p>
{/if}
</form>
{#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}
{#if latest.muscleMassKg !== null}<p>
<span class="stat">{num(latest.muscleMassKg, 1)}</span> kg muscle
</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)}%`
: ''}{scaleSummary(m) ? ` · ${scaleSummary(m)}` : ''}{m.source
? ` · ${m.source}`
: ''}{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>