diff --git a/scripts/seed-demo-apps01.sql b/scripts/seed-demo-apps01.sql new file mode 100644 index 000000000..b5b3ce4de --- /dev/null +++ b/scripts/seed-demo-apps01.sql @@ -0,0 +1,154 @@ +-- HealthLog demo seed for apps01 — v1.15 (cycle-inclusive, FEMALE demo). +-- +-- Creates / refreshes the `demo` account as a WOMAN with cycle tracking on, +-- NOW()-anchored + idempotent (re-runnable: wipes the demo user's rows first). +-- Showcases vitals + mood + the v1.15 cycle vertical (5 cycles, daily BBT with +-- a biphasic shift, mucus/OPK progression, period flow, phase-clustered +-- symptoms), plus phase-dependent RHR/HRV/sleep so the phase×vitals insights +-- board surfaces real findings. role=USER (login is open on prod; an +-- admin demo would be a risk). AI insights are generated separately afterwards. +-- +-- Run: ssh apps-01 'docker exec -i psql -U healthlog -d healthlog' < scripts/seed-demo-apps01.sql + +BEGIN; + +-- ── User (FEMALE, USER role) ────────────────────────────────────── +INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at) +SELECT 'usr_demo_' || replace(gen_random_uuid()::text, '-', ''), + 'demo', 'demo@healthlog.app', + '$argon2id$v=19$m=19456,t=2,p=1$3KvrEIuI5UnwG53GPbS71w$mL8ZQotIXn9iLFIw3yHXbh6SitX1w7TXKI5ahWcKkF8', + 'USER', 168, TIMESTAMP '1992-03-15', 'FEMALE', 'Europe/Berlin', 'en', NOW(), NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'demo'); + +UPDATE users SET + gender = 'FEMALE', role = 'USER', height_cm = 168, date_of_birth = TIMESTAMP '1992-03-15', + password_hash = '$argon2id$v=19$m=19456,t=2,p=1$3KvrEIuI5UnwG53GPbS71w$mL8ZQotIXn9iLFIw3yHXbh6SitX1w7TXKI5ahWcKkF8', + onboarding_completed_at = NOW(), timezone = 'Europe/Berlin', locale = 'en', updated_at = NOW() +WHERE username = 'demo'; + +CREATE TEMP TABLE _demo AS SELECT id AS user_id FROM users WHERE username = 'demo' LIMIT 1; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM _demo) THEN + RAISE EXCEPTION 'seed-demo-apps01: demo user not resolved'; + END IF; +END $$; + +-- ── Wipe prior demo rows (idempotent) ───────────────────────────── +DELETE FROM cycle_symptom_links WHERE day_log_id IN (SELECT id FROM cycle_day_logs WHERE user_id IN (SELECT user_id FROM _demo)); +DELETE FROM cycle_day_logs WHERE user_id IN (SELECT user_id FROM _demo); +DELETE FROM cycle_predictions WHERE user_id IN (SELECT user_id FROM _demo); +DELETE FROM menstrual_cycles WHERE user_id IN (SELECT user_id FROM _demo); +DELETE FROM mood_entries WHERE user_id IN (SELECT user_id FROM _demo); +DELETE FROM measurements WHERE user_id IN (SELECT user_id FROM _demo); +DELETE FROM measurement_rollups WHERE user_id IN (SELECT user_id FROM _demo); +DELETE FROM mood_entry_rollups WHERE user_id IN (SELECT user_id FROM _demo); + +-- ── Cycle profile (tracking ON) ─────────────────────────────────── +INSERT INTO cycle_profiles (id, user_id, goal, cycle_tracking_enabled, typical_cycle_length, typical_period_length, luteal_phase_length, prediction_enabled, raw_chart_mode, discreet_notifications, sensitive_category_encryption, created_at, updated_at) +SELECT 'cp_' || replace(gen_random_uuid()::text, '-', ''), user_id, 'GENERAL_HEALTH', true, 28, 5, 14, true, false, false, true, NOW(), NOW() +FROM _demo +WHERE NOT EXISTS (SELECT 1 FROM cycle_profiles cp JOIN _demo d ON cp.user_id = d.user_id); + +UPDATE cycle_profiles SET + cycle_tracking_enabled = true, goal = 'GENERAL_HEALTH', prediction_enabled = true, + typical_cycle_length = 28, typical_period_length = 5, luteal_phase_length = 14, updated_at = NOW() +WHERE user_id IN (SELECT user_id FROM _demo); + +-- ── Measurements (90-day sliding window, phase-aware) ───────────── +-- cycleDay(d) = ((128 - d) % 28) + 1 ; luteal = cycleDay > 13. +WITH demo AS (SELECT user_id FROM _demo), days AS (SELECT generate_series(0, 89) AS d) +INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, measured_at, created_at, updated_at) +SELECT 'm_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), v.type::measurement_type, v.value, v.unit, 'MANUAL'::measurement_source, + v.gc::glucose_context, + NOW() - (d || ' days')::interval - (random() * 5 || ' hours')::interval, + NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval +FROM days, +LATERAL ( + SELECT ((128 - d) % 28) + 1 AS cd +) c, +LATERAL (VALUES + ('WEIGHT', round((66.0 - (90 - d) * 0.011 + (random() - 0.5) * 0.5)::numeric, 1)::float8, 'kg', NULL), + ('BLOOD_PRESSURE_SYS', round((116 + (random() - 0.5) * 8)::numeric, 0)::float8, 'mmHg', NULL), + ('BLOOD_PRESSURE_DIA', round((74 + (random() - 0.5) * 6)::numeric, 0)::float8, 'mmHg', NULL), + ('PULSE', round((68 + (random() - 0.5) * 8)::numeric, 0)::float8, 'bpm', NULL), + ('RESTING_HEART_RATE', round((56 + (CASE WHEN c.cd > 13 THEN 3 ELSE 0 END) + (random() - 0.5) * 3)::numeric, 0)::float8, 'bpm', NULL), + ('HEART_RATE_VARIABILITY', round((68 - (CASE WHEN c.cd > 13 THEN 7 ELSE 0 END) + (random() - 0.5) * 6)::numeric, 0)::float8, 'ms', NULL), + ('SLEEP_DURATION', round((445 - (CASE WHEN c.cd > 13 THEN 18 ELSE 0 END) + (random() - 0.5) * 40)::numeric, 0)::float8, 'minutes', NULL), + ('ACTIVITY_STEPS', round((8200 + (random() - 0.5) * 3500)::numeric, 0)::float8, 'steps', NULL), + ('BODY_FAT', round((27.5 - (90 - d) * 0.006 + (random() - 0.5) * 0.4)::numeric, 1)::float8, '%', NULL), + ('BODY_TEMPERATURE', round(((CASE WHEN c.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, 'celsius', NULL), + ('BLOOD_GLUCOSE', round((92 + (random() - 0.5) * 18)::numeric, 0)::float8, 'mg/dL', 'FASTING') +) AS v(type, value, unit, gc); + +-- ── Mood (90 days, luteal dip) ──────────────────────────────────── +WITH demo AS (SELECT user_id FROM _demo), days AS (SELECT generate_series(0, 89) AS d) +INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, tz, sync_version, created_at, updated_at) +SELECT 'mo_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), + to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD'), + CASE s.score WHEN 1 THEN 'LAUSIG' WHEN 2 THEN 'SCHLECHT' WHEN 3 THEN 'OKAY' WHEN 4 THEN 'GUT' ELSE 'SUPER_GUT' END, + s.score, 'MANUAL', + NOW() - (d || ' days')::interval - (random() * 6 || ' hours')::interval, + NOW() - (d || ' days')::interval, 'Europe/Berlin', 0, + NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval +FROM days, +LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c, +LATERAL ( + SELECT GREATEST(1, LEAST(5, + round((CASE WHEN c.cd > 23 THEN 2.6 WHEN c.cd > 13 THEN 3.3 ELSE 3.9 END) + (random() - 0.5) * 1.6)::int + )) AS score +) s; + +-- ── Menstrual cycles (5, NOW-anchored; last open) ───────────────── +WITH offs AS (SELECT unnest(ARRAY[128, 100, 72, 44, 16]) AS off) +INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at) +SELECT 'mc_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM _demo), + to_char(NOW() - (off || ' days')::interval, 'YYYY-MM-DD'), + CASE WHEN off = 16 THEN NULL ELSE to_char(NOW() - ((off - 27) || ' days')::interval, 'YYYY-MM-DD') END, + to_char(NOW() - ((off - 4) || ' days')::interval, 'YYYY-MM-DD'), + CASE WHEN off = 16 THEN NULL ELSE 28 END, + to_char(NOW() - ((off - 13) || ' days')::interval, 'YYYY-MM-DD'), + true, false, 'Europe/Berlin', NOW(), NOW() +FROM offs; + +-- ── Cycle day-logs (128 days) + phase-clustered symptom links ───── +WITH demo AS (SELECT user_id FROM _demo), +gen AS ( + SELECT d, ((128 - d) % 28) + 1 AS cd, to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD') AS dstr + FROM generate_series(0, 127) AS d +), +ins AS ( + INSERT INTO cycle_day_logs (id, user_id, date, flow, basal_body_temp_c, cervical_mucus, ovulation_test, source, tz, sync_version, created_at, updated_at) + SELECT 'cdl_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), g.dstr, + (CASE g.cd WHEN 1 THEN 'MEDIUM' WHEN 2 THEN 'HEAVY' WHEN 3 THEN 'MEDIUM' WHEN 4 THEN 'LIGHT' WHEN 5 THEN 'SPOTTING' ELSE NULL END)::flow_level, + round(((CASE WHEN g.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, + (CASE WHEN g.cd BETWEEN 1 AND 5 THEN NULL WHEN g.cd BETWEEN 6 AND 9 THEN 'STICKY' WHEN g.cd BETWEEN 10 AND 12 THEN 'CREAMY' WHEN g.cd BETWEEN 13 AND 14 THEN 'EGG_WHITE' WHEN g.cd BETWEEN 15 AND 16 THEN 'WATERY' ELSE 'DRY' END)::cervical_mucus, + (CASE WHEN g.cd = 13 THEN 'POSITIVE_LH_SURGE' WHEN g.cd IN (11, 12, 14) THEN 'NEGATIVE' ELSE NULL END)::ovulation_test, + 'MANUAL'::measurement_source, 'Europe/Berlin', 0, + NOW() - (g.d || ' days')::interval, NOW() - (g.d || ' days')::interval + FROM gen g + RETURNING id, date +), +link_menstrual AS ( + INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at) + SELECT ins.id, s.id, (2 + floor(random() * 3))::int, NOW() + FROM ins JOIN gen g ON g.dstr = ins.date + CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('cramps', 'headache', 'fatigue')) s + WHERE g.cd BETWEEN 1 AND 3 +) +INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at) +SELECT ins.id, s.id, (1 + floor(random() * 3))::int, NOW() +FROM ins JOIN gen g ON g.dstr = ins.date +CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('mood_swings', 'bloating', 'breast_tenderness')) s +WHERE g.cd BETWEEN 24 AND 28; + +COMMIT; + +-- ── Summary ─────────────────────────────────────────────────────── +SELECT 'demo user' AS what, id FROM users WHERE username = 'demo'; +SELECT 'measurements' AS what, count(*) FROM measurements WHERE user_id = (SELECT id FROM users WHERE username = 'demo'); +SELECT 'mood' AS what, count(*) FROM mood_entries WHERE user_id = (SELECT id FROM users WHERE username = 'demo'); +SELECT 'cycles' AS what, count(*) FROM menstrual_cycles WHERE user_id = (SELECT id FROM users WHERE username = 'demo'); +SELECT 'day_logs' AS what, count(*) FROM cycle_day_logs WHERE user_id = (SELECT id FROM users WHERE username = 'demo'); +SELECT 'symptom_links' AS what, count(*) FROM cycle_symptom_links l JOIN cycle_day_logs dl ON l.day_log_id = dl.id WHERE dl.user_id = (SELECT id FROM users WHERE username = 'demo'); diff --git a/scripts/seed-demo-extend-1y.sql b/scripts/seed-demo-extend-1y.sql new file mode 100644 index 000000000..d611aea68 --- /dev/null +++ b/scripts/seed-demo-extend-1y.sql @@ -0,0 +1,216 @@ +-- HealthLog demo 1-year history extender for apps01. +-- +-- WHAT THIS DOES +-- Extends the demo account's data BACKWARD from the existing ~90-day seed +-- (scripts/seed-demo-apps01.sql) to ~365 days of coverage, and adds ~365 days +-- of realistic medication-intake history for the two pre-existing demo meds. +-- It mirrors that seed EXACTLY: same column lists, enum values, NOW()-anchored +-- offset math (cycleDay(d) = ((128 - d) % 28) + 1), cuid/uuid-id style, and the +-- same phase-aware vitals / mood / cycle channels. +-- +-- The seed covers offsets 0..89 (measurements + mood), 0..127 (cycle day-logs), +-- and 5 cycles at NOW-offsets {128,100,72,44,16}. This file ADDS ONLY the older +-- tail so nothing is duplicated: +-- - measurements / mood : offsets 90..364 (thinned to every 2nd day past ~180d) +-- - cycle day-logs : offsets 128..364 +-- - menstrual cycles : 7 more 28-day cycles at offsets 156..352 +-- - medication intakes : ~365d of due slots for both demo meds +-- +-- SCOPE + SAFETY +-- - Demo-user-scoped: EVERY statement filters on the hardcoded demo user id. +-- - Idempotent: every INSERT guards on the live unique index via ON CONFLICT +-- DO NOTHING (or a date-offset window that begins past the seed's coverage), +-- so re-running adds nothing new. +-- - NO DDL (no CREATE/ALTER/DROP/TRUNCATE), no changes to any other user, +-- no table-wide DELETE/UPDATE. +-- +-- Run: ssh apps-01 'docker exec -i psql -U healthlog -d healthlog' < scripts/seed-demo-extend-1y.sql + +\set uid 'usr_demo_cf31025295714ece8d91f5af13afd76d' +\set medA 'cmq2lzjqh005x01nrys2crzg9' -- Mounjaro: weekly @ 08:00 (rolling 7d) +\set medB 'cmq2m0mzk006101nrdpijbij0' -- Ramipril: twice daily @ 08:00 + 18:00 + +BEGIN; + +-- Refuse to run if the demo user does not exist. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN + RAISE EXCEPTION 'seed-demo-extend-1y: demo user not found'; + END IF; +END $$; + +-- ── Measurements: offsets 90..364, phase-aware (mirrors the seed channels) ───── +-- Thin the deep tail: keep every day for 90..179, every 2nd day for 180..364, to +-- keep row volume sane while still covering the full year. Unique index +-- (user_id, type, measured_at, source, sleep_stage) NULLS NOT DISTINCT — but +-- measured_at carries a random sub-day jitter, so collisions are effectively +-- impossible on a fresh tail; ON CONFLICT DO NOTHING makes re-runs safe anyway. +WITH days AS ( + SELECT d FROM generate_series(90, 364) AS d + WHERE d < 180 OR (d % 2) = 0 +) +INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, measured_at, created_at, updated_at) +SELECT 'm_' || replace(gen_random_uuid()::text, '-', ''), + 'usr_demo_cf31025295714ece8d91f5af13afd76d', + v.type::measurement_type, v.value, v.unit, 'MANUAL'::measurement_source, + v.gc::glucose_context, + NOW() - (d || ' days')::interval - (random() * 5 || ' hours')::interval, + NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval +FROM days, +LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c, +LATERAL (VALUES + ('WEIGHT', round((66.0 - (90 - d) * 0.011 + (random() - 0.5) * 0.5)::numeric, 1)::float8, 'kg', NULL), + ('BLOOD_PRESSURE_SYS', round((116 + (random() - 0.5) * 8)::numeric, 0)::float8, 'mmHg', NULL), + ('BLOOD_PRESSURE_DIA', round((74 + (random() - 0.5) * 6)::numeric, 0)::float8, 'mmHg', NULL), + ('PULSE', round((68 + (random() - 0.5) * 8)::numeric, 0)::float8, 'bpm', NULL), + ('RESTING_HEART_RATE', round((56 + (CASE WHEN c.cd > 13 THEN 3 ELSE 0 END) + (random() - 0.5) * 3)::numeric, 0)::float8, 'bpm', NULL), + ('HEART_RATE_VARIABILITY', round((68 - (CASE WHEN c.cd > 13 THEN 7 ELSE 0 END) + (random() - 0.5) * 6)::numeric, 0)::float8, 'ms', NULL), + ('SLEEP_DURATION', round((445 - (CASE WHEN c.cd > 13 THEN 18 ELSE 0 END) + (random() - 0.5) * 40)::numeric, 0)::float8, 'minutes', NULL), + ('ACTIVITY_STEPS', round((8200 + (random() - 0.5) * 3500)::numeric, 0)::float8, 'steps', NULL), + ('BODY_FAT', round((27.5 - (90 - d) * 0.006 + (random() - 0.5) * 0.4)::numeric, 1)::float8, '%', NULL), + ('BODY_TEMPERATURE', round(((CASE WHEN c.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, 'celsius', NULL), + ('BLOOD_GLUCOSE', round((92 + (random() - 0.5) * 18)::numeric, 0)::float8, 'mg/dL', 'FASTING') +) AS v(type, value, unit, gc) +ON CONFLICT DO NOTHING; + +-- ── Mood: offsets 90..364, luteal dip (mirrors the seed) ─────────────────────── +-- Unique index (user_id, date, mood_logged_at). One entry per day, fresh tail. +WITH days AS (SELECT generate_series(90, 364) AS d) +INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, tz, sync_version, created_at, updated_at) +SELECT 'mo_' || replace(gen_random_uuid()::text, '-', ''), + 'usr_demo_cf31025295714ece8d91f5af13afd76d', + to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD'), + CASE s.score WHEN 1 THEN 'LAUSIG' WHEN 2 THEN 'SCHLECHT' WHEN 3 THEN 'OKAY' WHEN 4 THEN 'GUT' ELSE 'SUPER_GUT' END, + s.score, 'MANUAL', + NOW() - (d || ' days')::interval - (random() * 6 || ' hours')::interval, + NOW() - (d || ' days')::interval, 'Europe/Berlin', 0, + NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval +FROM days, +LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c, +LATERAL ( + SELECT GREATEST(1, LEAST(5, + round((CASE WHEN c.cd > 23 THEN 2.6 WHEN c.cd > 13 THEN 3.3 ELSE 3.9 END) + (random() - 0.5) * 1.6)::int + )) AS score +) s +ON CONFLICT DO NOTHING; + +-- ── Menstrual cycles: 7 more 28-day cycles, offsets 156..352 ─────────────────── +-- Continues the seed's cadence backward (seed had {128,100,72,44,16}; next is 156). +-- Unique index (user_id, start_date) → ON CONFLICT DO NOTHING. All closed cycles. +WITH offs AS (SELECT unnest(ARRAY[156, 184, 212, 240, 268, 296, 324, 352]) AS off) +INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at) +SELECT 'mc_' || replace(gen_random_uuid()::text, '-', ''), + 'usr_demo_cf31025295714ece8d91f5af13afd76d', + to_char(NOW() - (off || ' days')::interval, 'YYYY-MM-DD'), + to_char(NOW() - ((off - 27) || ' days')::interval, 'YYYY-MM-DD'), + to_char(NOW() - ((off - 4) || ' days')::interval, 'YYYY-MM-DD'), + 28, + to_char(NOW() - ((off - 13) || ' days')::interval, 'YYYY-MM-DD'), + true, false, 'Europe/Berlin', NOW(), NOW() +FROM offs +ON CONFLICT (user_id, start_date) DO NOTHING; + +-- ── Cycle day-logs: offsets 128..364 + phase-clustered symptom links ─────────── +-- Mirrors the seed exactly (flow/BBT/mucus/OPK by cycleDay). Unique index +-- (user_id, date) → ON CONFLICT DO NOTHING. Symptom links only attach to rows +-- this run actually inserts (RETURNING), so re-runs add no duplicate links. +WITH gen AS ( + SELECT d, ((128 - d) % 28) + 1 AS cd, to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD') AS dstr + FROM generate_series(128, 364) AS d +), +ins AS ( + INSERT INTO cycle_day_logs (id, user_id, date, flow, basal_body_temp_c, cervical_mucus, ovulation_test, source, tz, sync_version, created_at, updated_at) + SELECT 'cdl_' || replace(gen_random_uuid()::text, '-', ''), + 'usr_demo_cf31025295714ece8d91f5af13afd76d', g.dstr, + (CASE g.cd WHEN 1 THEN 'MEDIUM' WHEN 2 THEN 'HEAVY' WHEN 3 THEN 'MEDIUM' WHEN 4 THEN 'LIGHT' WHEN 5 THEN 'SPOTTING' ELSE NULL END)::flow_level, + round(((CASE WHEN g.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, + (CASE WHEN g.cd BETWEEN 1 AND 5 THEN NULL WHEN g.cd BETWEEN 6 AND 9 THEN 'STICKY' WHEN g.cd BETWEEN 10 AND 12 THEN 'CREAMY' WHEN g.cd BETWEEN 13 AND 14 THEN 'EGG_WHITE' WHEN g.cd BETWEEN 15 AND 16 THEN 'WATERY' ELSE 'DRY' END)::cervical_mucus, + (CASE WHEN g.cd = 13 THEN 'POSITIVE_LH_SURGE' WHEN g.cd IN (11, 12, 14) THEN 'NEGATIVE' ELSE NULL END)::ovulation_test, + 'MANUAL'::measurement_source, 'Europe/Berlin', 0, + NOW() - (g.d || ' days')::interval, NOW() - (g.d || ' days')::interval + FROM gen g + ON CONFLICT (user_id, date) DO NOTHING + RETURNING id, date +), +link_menstrual AS ( + INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at) + SELECT ins.id, s.id, (2 + floor(random() * 3))::int, NOW() + FROM ins JOIN gen g ON g.dstr = ins.date + CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('cramps', 'headache', 'fatigue')) s + WHERE g.cd BETWEEN 1 AND 3 +) +INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at) +SELECT ins.id, s.id, (1 + floor(random() * 3))::int, NOW() +FROM ins JOIN gen g ON g.dstr = ins.date +CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('mood_swings', 'bloating', 'breast_tenderness')) s +WHERE g.cd BETWEEN 24 AND 28; + +-- ── Medication intakes: Med A (weekly @ 08:00 Berlin), ~365d ─────────────────── +-- The existing demo rows store scheduled_for in UTC at 06:00 (= 08:00 Berlin DST). +-- We mint slots at the Berlin local 08:00 and convert to the stored UTC wall-time +-- with AT TIME ZONE so winter (07:00 UTC) vs summer (06:00 UTC) is honoured. +-- Live partial unique index: (user_id, medication_id, scheduled_for, source) +-- WHERE deleted_at IS NULL → ON CONFLICT on that predicate makes re-runs safe. +-- Compliance ~85-90%: deterministic per-slot pseudo-random skip ~12% of weeks. +-- Never future-dated: WHERE the slot timestamp <= NOW(). +WITH slots AS ( + SELECT + ( (date_trunc('day', NOW() AT TIME ZONE 'Europe/Berlin') - (w * 7 || ' days')::interval + interval '8 hours') + AT TIME ZONE 'Europe/Berlin' ) AT TIME ZONE 'UTC' AS sched_utc, + -- deterministic skip decision per week offset (no random(): re-run stable) + ( (hashtextextended('medA-week-' || w, 0) & 2147483647)::numeric / 2147483647.0 ) AS r + FROM generate_series(0, 52) AS w -- 53 weekly slots = w*7 days back ≈ 371 days +) +INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at, sync_version) +SELECT 'cmq_demo_a_' || replace(gen_random_uuid()::text, '-', ''), + 'usr_demo_cf31025295714ece8d91f5af13afd76d', + 'cmq2lzjqh005x01nrys2crzg9', + s.sched_utc, + CASE WHEN s.r < 0.88 THEN s.sched_utc + (interval '5 minutes' + (s.r * interval '90 minutes')) ELSE NULL END, + CASE WHEN s.r < 0.88 THEN false ELSE true END, + 'WEB'::intake_source, + s.sched_utc, s.sched_utc, 0 +FROM slots s +WHERE s.sched_utc <= NOW() + -- skip slots already covered by the seeded rows (06-06 06:00 + 06-13 06:00) + AND s.sched_utc < date_trunc('day', NOW()) - interval '1 day' +ON CONFLICT (user_id, medication_id, scheduled_for, source) WHERE deleted_at IS NULL DO NOTHING; + +-- ── Medication intakes: Med B (twice daily @ 08:00 + 18:00 Berlin), ~365d ────── +-- One row per due slot per day. Compliance ~85-90%: deterministic per-slot skip +-- ~12% of individual slots. Same TZ + ON CONFLICT + no-future-date guards. +WITH slots AS ( + SELECT + ( (date_trunc('day', NOW() AT TIME ZONE 'Europe/Berlin') - (dd || ' days')::interval + (hh || ' hours')::interval) + AT TIME ZONE 'Europe/Berlin' ) AT TIME ZONE 'UTC' AS sched_utc, + ( (hashtextextended('medB-' || dd || '-' || hh, 0) & 2147483647)::numeric / 2147483647.0 ) AS r + FROM generate_series(0, 364) AS dd + CROSS JOIN (VALUES (8), (18)) AS h(hh) +) +INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at, sync_version) +SELECT 'cmq_demo_b_' || replace(gen_random_uuid()::text, '-', ''), + 'usr_demo_cf31025295714ece8d91f5af13afd76d', + 'cmq2m0mzk006101nrdpijbij0', + s.sched_utc, + CASE WHEN s.r < 0.88 THEN s.sched_utc + (interval '5 minutes' + (s.r * interval '120 minutes')) ELSE NULL END, + CASE WHEN s.r < 0.88 THEN false ELSE true END, + 'WEB'::intake_source, + s.sched_utc, s.sched_utc, 0 +FROM slots s +WHERE s.sched_utc <= NOW() + -- skip slots already covered by the seeded rows (today's 06:00 + 16:00 UTC) + AND s.sched_utc < date_trunc('day', NOW()) +ON CONFLICT (user_id, medication_id, scheduled_for, source) WHERE deleted_at IS NULL DO NOTHING; + +COMMIT; + +-- ── Verification (demo-user-scoped totals after apply) ───────────────────────── +\echo === post-apply totals (demo user) === +SELECT 'measurements' AS what, count(*), min(measured_at)::date AS oldest, max(measured_at)::date AS newest FROM measurements WHERE user_id = :'uid'; +SELECT 'mood' AS what, count(*), min(date) AS oldest, max(date) AS newest FROM mood_entries WHERE user_id = :'uid'; +SELECT 'cycles' AS what, count(*), min(start_date) AS oldest, max(start_date) AS newest FROM menstrual_cycles WHERE user_id = :'uid'; +SELECT 'day_logs' AS what, count(*), min(date) AS oldest, max(date) AS newest FROM cycle_day_logs WHERE user_id = :'uid'; +SELECT 'symptom_links' AS what, count(*) FROM cycle_symptom_links l JOIN cycle_day_logs dl ON l.day_log_id = dl.id WHERE dl.user_id = :'uid'; +SELECT 'intake_medA' AS what, count(*), count(*) FILTER (WHERE taken_at IS NOT NULL) AS taken, count(*) FILTER (WHERE skipped) AS skipped FROM medication_intake_events WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' AND medication_id = 'cmq2lzjqh005x01nrys2crzg9'; +SELECT 'intake_medB' AS what, count(*), count(*) FILTER (WHERE taken_at IS NOT NULL) AS taken, count(*) FILTER (WHERE skipped) AS skipped FROM medication_intake_events WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' AND medication_id = 'cmq2m0mzk006101nrdpijbij0'; diff --git a/scripts/seed-demo-scores-1y.sql b/scripts/seed-demo-scores-1y.sql new file mode 100644 index 000000000..d608b39b5 --- /dev/null +++ b/scripts/seed-demo-scores-1y.sql @@ -0,0 +1,153 @@ +-- HealthLog demo computed-wellness-score seeder for apps01 (1 year). +-- +-- WHAT THIS DOES +-- Seeds the three PERSISTED nightly wellness scores for the demo account so +-- all of the dashboard's "Deine Gesundheitswerte" rings render with a full +-- year of history. These three are normally written by the nightly score +-- crons (src/lib/jobs/{recovery,stress,strain}-score.ts) as COMPUTED +-- Measurement rows; the demo never had them computed, so only the two +-- compute-on-read composites (READINESS + SLEEP_SCORE — derived live from the +-- demo's RHR/HRV/sleep/mood, NOT stored) were showing. +-- +-- channel type external_id unit value +-- Recovery RECOVERY_SCORE recovery:YYYY-MM-DD score 0..100 (55-90) +-- Strain STRAIN_SCORE strain:YYYY-MM-DD score 0..100 (20-75) +-- Stress STRESS_SCORE stress:YYYY-MM-DD score 0..100 (25-65) +-- +-- READINESS and SLEEP_SCORE are NOT seeded here: neither exists in the +-- measurement_type enum (verified live) — both are recomputed on read from +-- the underlying signals the demo already carries, so they already render and +-- a stored row would be ignored. Seeding them would be dead data. +-- +-- Row shape mirrors src/lib/insights/score-row.ts::upsertScoreRow exactly: +-- source = COMPUTED, unit = 'score', measured_at = noon UTC on the scored day, +-- external_id = ':YYYY-MM-DD', sleep_stage / device_type / +-- external_source_version / deleted_at all NULL. +-- +-- Coverage: one row per (channel, UTC day) for the last 365 days (offsets +-- 1..365 — offset 0 is "today", which the nightly cron scores as the PREVIOUS +-- day, so the freshest persisted day is yesterday; this matches the job and +-- keeps every row strictly measured_at <= NOW()). 365 days x 3 channels = +-- 1,095 rows on a clean apply. +-- +-- DETERMINISM +-- Values + ids are hash-derived from the day stamp (hashtextextended / +-- md5 — NO random()), so a re-run is byte-stable. Idempotent re-runs are +-- additionally guarded by ON CONFLICT DO NOTHING on the live unique index +-- (user_id, type, source, external_id) — the exact key the cron upserts on. +-- +-- SCOPE + SAFETY +-- - Demo-user-scoped: EVERY statement hardcodes the demo user id. +-- - Idempotent: ON CONFLICT DO NOTHING; re-running adds nothing. +-- - No DDL (no CREATE/ALTER/DROP/TRUNCATE), no other-user writes, no +-- table-wide DELETE/UPDATE, no future-dated rows (measured_at <= NOW()). +-- +-- Run: ssh apps-01 'docker exec -i psql -U healthlog -d healthlog' < scripts/seed-demo-scores-1y.sql + +\set uid 'usr_demo_cf31025295714ece8d91f5af13afd76d' + +BEGIN; + +-- Refuse to run if the demo user does not exist. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN + RAISE EXCEPTION 'seed-demo-scores-1y: demo user not found'; + END IF; +END $$; + +-- ── The three persisted score channels, one row per (channel, day) ───────────── +-- offsets 1..365 (the scored day is the day that just ended; offset 0 = today is +-- never persisted by the cron). day_key is the UTC calendar day; measured_at is +-- noon UTC on it, mirroring scoreMeasuredAt(). Deterministic values: +-- - week phase : a gentle 7-day sinusoid keyed to the day-of-year (weekly +-- rhythm — lighter mid-week, heavier toward the weekend). +-- - noise : per-(channel,day) hash in [0,1), no random(). +-- - recovery : 55..90, dips after a "hard" (high-strain) day. +-- - strain : 20..75, weekend / mid-week training spikes (~25% of days). +-- - stress : 25..65, inversely coupled to recovery (recovery up → stress +-- down) so the two rings tell a coherent story. +WITH days AS ( + SELECT + d, + (NOW() AT TIME ZONE 'UTC')::date - d AS day_key + FROM generate_series(1, 365) AS d +), +calc AS ( + SELECT + d, + day_key, + (day_key + 12)::timestamp AS measured_at, -- noon UTC on the scored day + -- weekly rhythm in [-1, 1] from ISO day-of-week (Mon=1..Sun=7) + sin(2 * pi() * (extract(isodow FROM day_key)::numeric - 1) / 7.0) AS week_phase, + -- deterministic per-day "is this a training day" flag (~25%) + ( (hashtextextended('score-train-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS train_r, + -- independent deterministic noise streams per channel, all in [0,1) + ( (hashtextextended('score-rec-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS rec_r, + ( (hashtextextended('score-str-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS str_r, + ( (hashtextextended('score-stress-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS stress_r + FROM days +), +vals AS ( + SELECT + d, day_key, measured_at, train_r, + -- STRAIN 20..75: base + weekly rhythm + a spike on training days. + LEAST(75, GREATEST(20, round( + 40 + week_phase * 8 + + (CASE WHEN train_r < 0.25 THEN 22 ELSE 0 END) + + (str_r - 0.5) * 14 + )))::int AS strain, + -- RECOVERY 55..90: base + weekly rhythm, suppressed the day after a hard day. + LEAST(90, GREATEST(55, round( + 74 + week_phase * 6 + - (CASE WHEN train_r < 0.25 THEN 7 ELSE 0 END) + + (rec_r - 0.5) * 16 + )))::int AS recovery + FROM calc +), +final AS ( + SELECT + v.d, v.day_key, v.measured_at, v.strain, v.recovery, + -- STRESS 25..65: inverse to recovery (high recovery → low stress) + noise. + LEAST(65, GREATEST(25, round( + 90 - v.recovery * 0.5 + + (c.stress_r - 0.5) * 14 + )))::int AS stress + FROM vals v JOIN calc c USING (d) +), +rows AS ( + SELECT day_key, measured_at, type, ext_prefix, value FROM final, + LATERAL (VALUES + ('RECOVERY_SCORE', 'recovery:', recovery), + ('STRAIN_SCORE', 'strain:', strain), + ('STRESS_SCORE', 'stress:', stress) + ) AS ch(type, ext_prefix, value) +) +INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at) +SELECT + -- deterministic id from the per-row external_id → byte-stable re-runs. + 'm_score_' || md5(ext_prefix || to_char(day_key, 'YYYY-MM-DD')), + 'usr_demo_cf31025295714ece8d91f5af13afd76d', + type::measurement_type, + value::float8, + 'score', + 'COMPUTED'::measurement_source, + measured_at, + ext_prefix || to_char(day_key, 'YYYY-MM-DD'), + measured_at, + measured_at +FROM rows +WHERE measured_at <= NOW() +ON CONFLICT (user_id, type, source, external_id) DO NOTHING; + +COMMIT; + +-- ── Verification (demo-user-scoped, all five score channels) ─────────────────── +\echo === post-apply score coverage (demo user) === +SELECT type, count(*) AS rows, min(measured_at)::date AS oldest, max(measured_at)::date AS newest +FROM measurements +WHERE user_id = :'uid' + AND type IN ('RECOVERY_SCORE', 'STRAIN_SCORE', 'STRESS_SCORE') +GROUP BY type +ORDER BY type; +\echo (READINESS + SLEEP_SCORE are compute-on-read composites — not stored, render from underlying signals.) diff --git a/src/lib/doctor-report-pdf.ts b/src/lib/doctor-report-pdf.ts deleted file mode 100644 index 98ecf0a7f..000000000 --- a/src/lib/doctor-report-pdf.ts +++ /dev/null @@ -1,554 +0,0 @@ -/** - * Client-side PDF generation for doctor reports. - * - * Every string and every number/date is driven by an injected `t()` and - * locale-aware formatters so the exported PDF matches the user's UI language. - * Call sites must pass `{ t, locale }` (use the ones from `useTranslations()` - * / `useFormatters()`). - */ -import { jsPDF } from "jspdf"; -import autoTable from "jspdf-autotable"; -import { makeFormatters } from "./format-locale"; -import type { Locale } from "./i18n/config"; -import { convertGlucose, resolveGlucoseUnit } from "./glucose"; - -interface ReportData { - period: { days: number; since: string; start?: string; end?: string }; - patient: { - username: string | null; - dateOfBirth: string | null; - gender: string | null; - heightCm: number | null; - }; - /** Free-text practice / clinic name printed on the cover. Optional. */ - practiceName?: string | null; - stats: Record< - string, - { avg: number; min: number; max: number; count: number; latest: number } - >; - /** Per-context glucose stats (canonical mg/dL). */ - glucoseStats?: Record< - "FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME", - | { avg: number; min: number; max: number; count: number; latest: number } - | undefined - >; - /** Display-unit preference: "mg/dL" (default) or "mmol/L". */ - glucoseUnit?: "mg/dL" | "mmol/L"; - /** Per-context custom range (canonical mg/dL); fall back to defaults. */ - glucoseRanges?: Record< - "FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME", - { min: number; max: number } | undefined - >; - bmi: number | null; - compliance: Record< - string, - { total: number; taken: number; skipped: number; missed: number } - >; - medications: Array<{ - name: string; - dose: string; - schedules: Array<{ - windowStart: string; - windowEnd: string; - label: string | null; - }>; - }>; - mood: { - avg: number; - min: number; - max: number; - count: number; - distribution: Record; - } | null; -} - -type T = (key: string, params?: Record) => string; - -export interface DoctorReportOptions { - t: T; - locale: Locale; -} - -const TYPE_LABEL_KEYS: Record = { - WEIGHT: "doctorReport.typeWeight", - BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys", - BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia", - PULSE: "doctorReport.typePulse", - BODY_FAT: "doctorReport.typeBodyFat", - SLEEP_DURATION: "doctorReport.typeSleep", - ACTIVITY_STEPS: "doctorReport.typeSteps", - TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater", - BONE_MASS: "doctorReport.typeBoneMass", - OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation", -}; - -const TYPE_UNIT_KEYS: Record = { - WEIGHT: "kg", - BLOOD_PRESSURE_SYS: "mmHg", - BLOOD_PRESSURE_DIA: "mmHg", - PULSE: "bpm", - BODY_FAT: "%", - SLEEP_DURATION: "h", - ACTIVITY_STEPS: null, // translated unit - TOTAL_BODY_WATER: "kg", - BONE_MASS: "kg", - OXYGEN_SATURATION: "%", -}; - -const MOOD_LABEL_KEYS: Record = { - 1: "doctorReport.moodAwful", - 2: "doctorReport.moodBad", - 3: "doctorReport.moodNeutral", - 4: "doctorReport.moodGood", - 5: "doctorReport.moodGreat", -}; - -function getBmiClassificationKey(bmi: number): string { - if (bmi < 18.5) return "doctorReport.bmiUnderweight"; - if (bmi < 25) return "doctorReport.bmiNormal"; - if (bmi < 30) return "doctorReport.bmiOverweight"; - if (bmi < 35) return "doctorReport.bmiObeseGrade1"; - if (bmi < 40) return "doctorReport.bmiObeseGrade2"; - return "doctorReport.bmiObeseGrade3"; -} - -function getBpClassificationKey(sys: number, dia: number): string { - if (sys < 120 && dia < 80) return "doctorReport.bpOptimal"; - if (sys < 130 && dia < 85) return "doctorReport.bpNormal"; - if (sys < 140 && dia < 90) return "doctorReport.bpHighNormal"; - if (sys < 160 && dia < 100) return "doctorReport.bpHypertensionGrade1"; - if (sys < 180 && dia < 110) return "doctorReport.bpHypertensionGrade2"; - return "doctorReport.bpHypertensionGrade3"; -} - -export function generateDoctorReportPDF( - data: ReportData, - options: DoctorReportOptions, -): jsPDF { - const { t, locale } = options; - const formatters = makeFormatters(locale); - const num = (value: number, decimals = 1) => - formatters.number(value, decimals); - const fmtDate = (iso: string) => formatters.date(iso); - - const unitFor = (type: string): string => { - // Map entry === null means the unit needs translation (e.g. ACTIVITY_STEPS). - const staticUnit = TYPE_UNIT_KEYS[type]; - if (staticUnit === null && type === "ACTIVITY_STEPS") { - return t("doctorReport.unitSteps"); - } - return staticUnit ?? ""; - }; - - const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); - const pageWidth = doc.internal.pageSize.getWidth(); - const margin = 20; - let y = margin; - - doc.setFontSize(22); - doc.setFont("helvetica", "bold"); - doc.setTextColor(30, 30, 30); - doc.text(t("doctorReport.title"), margin, y); - y += 8; - - doc.setFontSize(10); - doc.setFont("helvetica", "normal"); - doc.setTextColor(100, 100, 100); - doc.text(t("doctorReport.subtitle"), margin, y); - y += 6; - - // Practice / clinic name (optional). Rendered prominently above the - // separator so the addressee is visible at first glance. - if (data.practiceName) { - doc.setFontSize(11); - doc.setFont("helvetica", "bold"); - doc.setTextColor(30, 30, 30); - doc.text( - `${t("doctorReport.practiceLabel")}: ${data.practiceName}`, - margin, - y, - ); - y += 6; - doc.setFont("helvetica", "normal"); - } - - doc.setDrawColor(200, 200, 200); - doc.setLineWidth(0.5); - doc.line(margin, y, pageWidth - margin, y); - y += 8; - - doc.setFontSize(9); - doc.setTextColor(80, 80, 80); - const patientInfo: string[] = []; - if (data.patient.username) { - patientInfo.push(`${t("doctorReport.patient")}: ${data.patient.username}`); - } - if (data.patient.dateOfBirth) { - patientInfo.push( - `${t("doctorReport.dateOfBirth")}: ${fmtDate(data.patient.dateOfBirth)}`, - ); - } - if (data.patient.gender) { - const genderKeys: Record = { - MALE: "doctorReport.genderMale", - FEMALE: "doctorReport.genderFemale", - }; - const genderKey = - genderKeys[data.patient.gender] ?? "doctorReport.genderOther"; - patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`); - } - if (data.patient.heightCm) { - patientInfo.push( - `${t("doctorReport.height")}: ${data.patient.heightCm} cm`, - ); - } - // Reporting period uses explicit start/end when available; falls back to - // (since, now()) for legacy payloads. - const periodStart = data.period.start ?? data.period.since; - const periodEnd = data.period.end ?? new Date().toISOString(); - patientInfo.push( - `${t("doctorReport.period")}: ${fmtDate(periodStart)} — ${fmtDate(periodEnd)}`, - ); - patientInfo.push( - `${t("doctorReport.createdOn")}: ${fmtDate(new Date().toISOString())}`, - ); - - for (const line of patientInfo) { - doc.text(line, margin, y); - y += 4.5; - } - y += 4; - - doc.setFontSize(14); - doc.setFont("helvetica", "bold"); - doc.setTextColor(30, 30, 30); - doc.text(t("doctorReport.vitalsTitle"), margin, y); - y += 6; - - const vitalRows: string[][] = []; - const vitalTypes = [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - "PULSE", - "BODY_FAT", - "TOTAL_BODY_WATER", - "BONE_MASS", - "OXYGEN_SATURATION", - ]; - - for (const type of vitalTypes) { - const s = data.stats[type]; - if (!s) continue; - const unit = unitFor(type); - vitalRows.push([ - t(TYPE_LABEL_KEYS[type] ?? ""), - `${num(s.latest)} ${unit}`.trim(), - `${num(s.avg)} ${unit}`.trim(), - num(s.min), - num(s.max), - String(s.count), - ]); - } - - // Per-context glucose rows (one per logged context). Values stored - // canonically in mg/dL — convert to the user's display unit. Reference - // ranges come from getEffectiveRange() server-side via data.glucoseRanges, - // falling back to ADA defaults below. - const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null); - const glucoseLabelKeys = { - FASTING: "doctorReport.typeGlucoseFasting", - POSTPRANDIAL: "doctorReport.typeGlucosePostprandial", - RANDOM: "doctorReport.typeGlucoseRandom", - BEDTIME: "doctorReport.typeGlucoseBedtime", - } as const; - const defaultGlucoseRanges = { - FASTING: { min: 70, max: 99 }, - POSTPRANDIAL: { min: 70, max: 140 }, - RANDOM: { min: 70, max: 140 }, - BEDTIME: { min: 90, max: 150 }, - } as const; - const glucoseContexts: Array = [ - "FASTING", - "POSTPRANDIAL", - "RANDOM", - "BEDTIME", - ]; - for (const ctx of glucoseContexts) { - const s = data.glucoseStats?.[ctx]; - if (!s) continue; - const conv = (v: number) => convertGlucose(v, glucoseUnit); - vitalRows.push([ - t(glucoseLabelKeys[ctx]), - `${num(conv(s.latest))} ${glucoseUnit}`.trim(), - `${num(conv(s.avg))} ${glucoseUnit}`.trim(), - num(conv(s.min)), - num(conv(s.max)), - String(s.count), - ]); - } - - if (vitalRows.length > 0) { - autoTable(doc, { - startY: y, - head: [ - [ - t("doctorReport.colParameter"), - t("doctorReport.colCurrent"), - t("doctorReport.colAvgPeriod"), - t("doctorReport.colMin"), - t("doctorReport.colMax"), - t("doctorReport.colN"), - ], - ], - body: vitalRows, - theme: "grid", - styles: { - fontSize: 9, - cellPadding: 3, - textColor: [30, 30, 30], - lineColor: [200, 200, 200], - lineWidth: 0.3, - }, - headStyles: { - fillColor: [245, 245, 245], - textColor: [30, 30, 30], - fontStyle: "bold", - }, - alternateRowStyles: { fillColor: [252, 252, 252] }, - margin: { left: margin, right: margin }, - }); - y = - (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable - .finalY + 8; - } - - const sysStat = data.stats.BLOOD_PRESSURE_SYS; - const diaStat = data.stats.BLOOD_PRESSURE_DIA; - if (sysStat && diaStat) { - doc.setFontSize(12); - doc.setFont("helvetica", "bold"); - doc.text(t("doctorReport.bpClassificationTitle"), margin, y); - y += 5; - - doc.setFontSize(9); - doc.setFont("helvetica", "normal"); - const bpClass = t(getBpClassificationKey(sysStat.avg, diaStat.avg)); - doc.text( - `${t("doctorReport.avgBp")}: ${num(sysStat.avg, 0)}/${num(diaStat.avg, 0)} mmHg — ${t("doctorReport.classification")}: ${bpClass}`, - margin, - y, - ); - y += 8; - } - - if (data.bmi) { - doc.setFontSize(12); - doc.setFont("helvetica", "bold"); - doc.text(t("doctorReport.bmiTitle"), margin, y); - y += 5; - - doc.setFontSize(9); - doc.setFont("helvetica", "normal"); - doc.text( - t("doctorReport.bmiRow", { - bmi: num(data.bmi, 1), - class: t(getBmiClassificationKey(data.bmi)), - }), - margin, - y, - ); - y += 8; - } - - // Glucose classification block — one line per logged context with the - // reference range and an in/out classification. Range source: server-side - // getEffectiveRange() (data.glucoseRanges). Falls back to ADA defaults. - const loggedGlucose = glucoseContexts.filter( - (ctx) => data.glucoseStats?.[ctx], - ); - if (loggedGlucose.length > 0) { - if (y > 240) { - doc.addPage(); - y = margin; - } - doc.setFontSize(12); - doc.setFont("helvetica", "bold"); - doc.text(t("doctorReport.glucoseClassificationTitle"), margin, y); - y += 5; - doc.setFontSize(9); - doc.setFont("helvetica", "normal"); - for (const ctx of loggedGlucose) { - const s = data.glucoseStats![ctx]!; - const range = data.glucoseRanges?.[ctx] ?? defaultGlucoseRanges[ctx]; - const conv = (v: number) => convertGlucose(v, glucoseUnit); - const inRange = s.avg >= range.min && s.avg <= range.max; - const classKey = inRange - ? "doctorReport.glucoseInTarget" - : s.avg < range.min - ? "doctorReport.glucoseBelowTarget" - : "doctorReport.glucoseAboveTarget"; - doc.text( - t("doctorReport.glucoseRow", { - label: t(glucoseLabelKeys[ctx]), - avg: num(conv(s.avg), 1), - unit: glucoseUnit, - rangeMin: num(conv(range.min)), - rangeMax: num(conv(range.max)), - class: t(classKey), - }), - margin, - y, - ); - y += 5; - } - y += 3; - } - - const complianceEntries = Object.entries(data.compliance); - if (complianceEntries.length > 0) { - if (y > 240) { - doc.addPage(); - y = margin; - } - - doc.setFontSize(14); - doc.setFont("helvetica", "bold"); - doc.text(t("doctorReport.complianceTitle"), margin, y); - y += 6; - - const compRows = complianceEntries.map(([name, c]) => { - const rate = c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—"; - return [ - name, - String(c.taken), - String(c.skipped), - String(c.missed), - String(c.total), - rate, - ]; - }); - - autoTable(doc, { - startY: y, - head: [ - [ - t("doctorReport.colMedication"), - t("doctorReport.colTaken"), - t("doctorReport.colSkipped"), - t("doctorReport.colMissed"), - t("doctorReport.colTotal"), - t("doctorReport.colComplianceRate"), - ], - ], - body: compRows, - theme: "grid", - styles: { - fontSize: 9, - cellPadding: 3, - textColor: [30, 30, 30], - lineColor: [200, 200, 200], - lineWidth: 0.3, - }, - headStyles: { - fillColor: [245, 245, 245], - textColor: [30, 30, 30], - fontStyle: "bold", - }, - alternateRowStyles: { fillColor: [252, 252, 252] }, - margin: { left: margin, right: margin }, - }); - y = - (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable - .finalY + 8; - } - - if (data.mood) { - if (y > 240) { - doc.addPage(); - y = margin; - } - - doc.setFontSize(14); - doc.setFont("helvetica", "bold"); - doc.text(t("doctorReport.moodTitle"), margin, y); - y += 6; - - doc.setFontSize(9); - doc.setFont("helvetica", "normal"); - doc.text( - t("doctorReport.moodSummary", { - avg: num(data.mood.avg, 1), - count: data.mood.count, - min: num(data.mood.min, 0), - max: num(data.mood.max, 0), - }), - margin, - y, - ); - y += 6; - - const distRows = Object.entries(data.mood.distribution).map( - ([score, count]) => [ - t(MOOD_LABEL_KEYS[Number(score)] ?? "doctorReport.moodNeutral"), - String(count), - data.mood!.count > 0 - ? `${num((count / data.mood!.count) * 100, 1)}%` - : "0%", - ], - ); - - autoTable(doc, { - startY: y, - head: [ - [ - t("doctorReport.colMood"), - t("doctorReport.colCount"), - t("doctorReport.colShare"), - ], - ], - body: distRows, - theme: "grid", - styles: { - fontSize: 9, - cellPadding: 3, - textColor: [30, 30, 30], - lineColor: [200, 200, 200], - lineWidth: 0.3, - }, - headStyles: { - fillColor: [245, 245, 245], - textColor: [30, 30, 30], - fontStyle: "bold", - }, - margin: { left: margin, right: margin }, - }); - y = - (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable - .finalY + 8; - } - - const addFooter = (pageDoc: jsPDF) => { - const pageHeight = pageDoc.internal.pageSize.getHeight(); - pageDoc.setFontSize(7); - pageDoc.setFont("helvetica", "italic"); - pageDoc.setTextColor(140, 140, 140); - pageDoc.text(t("doctorReport.footerDisclaimer1"), margin, pageHeight - 14); - pageDoc.text(t("doctorReport.footerDisclaimer2"), margin, pageHeight - 10); - pageDoc.text( - t("doctorReport.footerSource", { - timestamp: formatters.dateTime(new Date()), - }), - margin, - pageHeight - 6, - ); - }; - - const totalPages = doc.getNumberOfPages(); - for (let i = 1; i <= totalPages; i++) { - doc.setPage(i); - addFooter(doc); - } - - return doc; -}