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