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