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