/** * Demo Data Seed Script for HealthLog * * Creates a realistic, aspirational demo tenant for the public `demo` * account (demo / demo123demo123 — credentials are intentionally public). * The shape is tuned so a fresh seed reads healthy, engaged, and current * for App-Store / marketing screenshots: * * - 1 admin user (demo/demo123demo123) * - 90 days of measurements (weight, BP, pulse, resting HR, body fat, * sleep, steps) — flat-or-improving trends, most-recent point from today * - A full week of per-stage sleep nights (awake/REM/light(core)/deep), * ~7.5 h each, including last night * - 90 days of body composition (fat/lean/muscle mass, total body water, * bone mass, visceral fat, BMI) derived from the same-day weight + body fat * - 90 days of blood glucose in a healthy non-diabetic band (fasting + * post-meal + bedtime, contexts tagged) * - 90 days of cardio fitness + vitals (HRV SDNN + RMSSD, SpO2, respiratory * rate, VO2 max, active energy, walking/running distance, flights climbed) * - 90 days of WHOOP-style scores (recovery, day strain, sleep * performance/efficiency/consistency) coherent with the sleep nights * - ~3-4 workouts/week (running, strength, cycling) with per-workout HR * samples and per-workout strain * - 3 medications with schedules and ~90 days of intake history at high * compliance, with today scheduled on-track (taken or not-yet-due) * - 90 days of mood entries * - Vorsorge (preventive-care) reminders — upcoming dental + annual physical * - One lab panel of biomarkers in healthy ranges * - One resolved illness episode in the past * - An AI-configured Coach with one short sample conversation * - App settings (registration disabled, English locale) * * Every date is relative to "now" so the demo stays fresh on every re-seed. * * Usage: npx tsx scripts/seed-demo.ts * Requires DATABASE_URL env var. The Coach sample conversation additionally * needs ENCRYPTION_KEYS / ENCRYPTION_KEY (the same key the app runs with). */ import { Buffer } from "node:buffer"; import pg from "pg"; import { encryptToBytes } from "../src/lib/ai/coach/bytes-codec"; import { encrypt } from "../src/lib/crypto"; import { VALUE_RANGES } from "../src/lib/validations/measurement"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { console.error("DATABASE_URL is required"); process.exit(1); } const pool = new pg.Pool({ connectionString: DATABASE_URL }); // ── Helpers ────────────────────────────────────── function cuid(): string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let id = "c"; for (let i = 0; i < 24; i++) { id += chars[Math.floor(Math.random() * chars.length)]; } return id; } function daysAgo(n: number): Date { const d = new Date(); d.setDate(d.getDate() - n); d.setHours( 7 + Math.floor(Math.random() * 3), Math.floor(Math.random() * 60), 0, 0, ); return d; } // A specific local instant N days ago, at a fixed wall-clock time. Used for // the per-stage sleep segments and the on-track intake slots where the hour // matters (a random morning hour would scatter the sleep stages). function daysAgoAt(n: number, hour: number, minute = 0): Date { const d = new Date(); d.setDate(d.getDate() - n); d.setHours(hour, minute, 0, 0); return d; } function formatDate(d: Date): string { return d.toISOString().split("T")[0]; } // The UTC instant of `hh:mm` Europe/Berlin local on the Berlin calendar day // that contains `ref`. This mirrors `localHmAsUtc` (src/lib/tz/local-day.ts): // the medication scheduling engine mints today's dose-slot anchor as the UTC // instant of the schedule's window_start in the user's timezone (the demo // user is Europe/Berlin). The dashboard's slot-resolution matches a taken // intake row to a slot only when the row's `scheduled_for` sits on (or, for a // slot-anchored row, within ±6h of) that canonical instant. We must compute // the same instant here — independent of the seed host's own clock zone — so // today's taken rows satisfy the 08:00 slot rather than floating beside it. function berlinHmAsUtc(hour: number, minute = 0, ref: Date = new Date()): Date { const fmt = new Intl.DateTimeFormat("en-US", { timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }); const parts = Object.fromEntries( fmt.formatToParts(ref).map((p) => [p.type, p.value]), ); const y = Number(parts.year); const mo = Number(parts.month); const d = Number(parts.day); // `hour` can come back as "24" at midnight in some runtimes; normalise. const refHour = Number(parts.hour) % 24; const localTargetAsUtc = Date.UTC(y, mo - 1, d, hour, minute, 0, 0); const localNowAsUtc = Date.UTC( y, mo - 1, d, refHour, Number(parts.minute), Number(parts.second), ); const offsetMs = Math.round((localNowAsUtc - ref.getTime()) / 60000) * 60000; return new Date(localTargetAsUtc - offsetMs); } // The day key the insight read paths compare against. The per-metric status // cards stamp `dateKey = toBerlinDayKey(now)` (src/lib/tz/resolver.ts) and the // read serves a row only when its `dateKey` equals today's Berlin key. The // demo user is Europe/Berlin, so we mint the same key here so the baked cards // always read as "today" on every re-seed, never stale. function berlinDayKey(d: Date = new Date()): string { return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit", }).format(d); } // Smooth random walk with mean reversion function randomWalk( start: number, target: number, days: number, volatility: number, // Optional physiological band. A free walk can drift below the floor and // mint an out-of-range row (a systolic-0 BP reading was observed in a // fresh seed — iOS #33); clamping every step keeps the series inside the // same band the input validator enforces. clamp?: { min: number; max: number }, ): number[] { const values: number[] = [start]; for (let i = 1; i < days; i++) { const prev = values[i - 1]; const drift = (target - prev) * 0.03; // mean reversion const noise = (Math.random() - 0.5) * volatility; let next = Math.round((prev + drift + noise) * 10) / 10; if (clamp) next = Math.min(clamp.max, Math.max(clamp.min, next)); values.push(next); } return values; } // ── Main ───────────────────────────────────────── async function seed() { const client = await pool.connect(); try { await client.query("BEGIN"); console.log("Cleaning existing data..."); await client.query("DELETE FROM coach_messages"); await client.query("DELETE FROM coach_conversations"); await client.query("DELETE FROM consent_receipts"); await client.query("DELETE FROM insight_narratives"); await client.query("DELETE FROM illness_episodes"); await client.query("DELETE FROM lab_results"); await client.query("DELETE FROM measurement_reminders"); await client.query("DELETE FROM mood_entries"); await client.query("DELETE FROM workout_samples"); await client.query("DELETE FROM workouts"); await client.query("DELETE FROM medication_intake_events"); await client.query("DELETE FROM medication_schedules"); await client.query("DELETE FROM reminder_phase_configs"); await client.query("DELETE FROM medications"); await client.query("DELETE FROM measurements"); await client.query("DELETE FROM user_achievements"); await client.query("DELETE FROM audit_logs"); await client.query("DELETE FROM sessions"); await client.query("DELETE FROM passkeys"); await client.query("DELETE FROM auth_challenges"); await client.query("DELETE FROM notification_channels"); await client.query("DELETE FROM push_subscriptions"); await client.query("DELETE FROM api_tokens"); await client.query("DELETE FROM data_backups"); await client.query("DELETE FROM users"); // ── User ────────────────────────────────── console.log("Creating demo user..."); const userId = cuid(); // argon2id hash of "demo123demo123" — pre-computed // We'll use a bcrypt-compatible approach: register via API later // For now, insert with a placeholder and we'll set it via the app const passwordHash = "$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI"; // AI Coach is configured to the Anthropic provider so the Coach surface // reads as connected. The stored key is a placeholder ciphertext: the // public demo never makes a live provider call (the sample conversation // below supplies the visible content), so no real credential is seeded. const aiProvider = "ANTHROPIC"; const aiModel = "claude-3-5-sonnet"; const aiKeyEncrypted = encrypt("sk-ant-demo-placeholder-not-a-live-key"); await client.query( `INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, ai_provider, ai_model, ai_anthropic_key_encrypted, onboarding_completed_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW(), NOW())`, [ userId, "demo", "demo@healthlog.app", passwordHash, "ADMIN", 182.0, "1990-05-15", "MALE", "Europe/Berlin", "en", aiProvider, aiModel, aiKeyEncrypted, ], ); // ── Measurements (90 days) ──────────────── // The loop runs i = 0..days (inclusive) so the final iteration lands a // point for TODAY (daysAgo(0)) — every headline metric has a current // reading for the dashboard, not one from yesterday. console.log("Creating 90 days of measurements..."); const days = 90; const span = days + 1; // include today // Weight: 86.5 → trending down to a stable ~82 (healthy BMI ~24.7 at 182cm) const weights = randomWalk(86.5, 82.0, span, 0.6); // Systolic BP: 128 → settled in the optimal band ~118 (clamped) const sysBP = randomWalk( 128, 118, span, 3, VALUE_RANGES.BLOOD_PRESSURE_SYS, ); // Diastolic BP: 82 → settled ~76 (clamped) const diaBP = randomWalk( 82, 76, span, 2.5, VALUE_RANGES.BLOOD_PRESSURE_DIA, ); // Spot pulse (daytime heart rate): ~72, gently lower const pulse = randomWalk(74, 70, span, 3, VALUE_RANGES.PULSE); // Resting heart rate: clean ~60, the metric the resting-pulse tile scores const restingHr = randomWalk( 64, 58, span, 2, VALUE_RANGES.RESTING_HEART_RATE, ); // Body fat: 24% → trending to a healthy ~19% const bodyFat = randomWalk(24.0, 19.0, span, 0.4, VALUE_RANGES.BODY_FAT); // Sleep duration in MINUTES (the SLEEP_DURATION unit): ~7h → ~7h45m const sleepMin = randomWalk( 420, 465, span, 25, VALUE_RANGES.SLEEP_DURATION, ); // Steps: 6500 → a solid ~9000 const steps = randomWalk(6500, 9000, span, 1500); for (let i = 0; i < span; i++) { const date = daysAgo(days - i); // Weight (daily) await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'WEIGHT', $3, 'kg', 'MANUAL', $4, $4, $4)`, [cuid(), userId, weights[i], date], ); // Blood pressure (daily) await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'BLOOD_PRESSURE_SYS', $3, 'mmHg', 'MANUAL', $4, $4, $4)`, [cuid(), userId, Math.round(sysBP[i]), date], ); await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'BLOOD_PRESSURE_DIA', $3, 'mmHg', 'MANUAL', $4, $4, $4)`, [cuid(), userId, Math.round(diaBP[i]), date], ); // Spot pulse (daily) await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'PULSE', $3, 'bpm', 'MANUAL', $4, $4, $4)`, [cuid(), userId, Math.round(pulse[i]), date], ); // Resting heart rate (daily) — scored by the resting-pulse tile await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'RESTING_HEART_RATE', $3, 'bpm', 'MANUAL', $4, $4, $4)`, [cuid(), userId, Math.round(restingHr[i]), date], ); // Body fat (every 2-3 days) if (i % 2 === 0 || Math.random() > 0.5) { await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'BODY_FAT', $3, '%', 'MANUAL', $4, $4, $4)`, [cuid(), userId, bodyFat[i], date], ); } // Sleep duration (daily, in minutes). This single aggregate row keeps // the 90-day sleep series continuous for the trend chart. The most // recent seven nights are instead written as per-stage breakdowns below // (one row per stage); skip the aggregate for those so a night is not // double-counted (aggregate + summed stages). const nightsAgo = days - i; if (nightsAgo >= 7) { await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'SLEEP_DURATION', $3, 'minutes', 'MANUAL', $4, $4, $4)`, [cuid(), userId, Math.round(sleepMin[i]), date], ); } // Steps (daily) await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'ACTIVITY_STEPS', $3, 'steps', 'MANUAL', $4, $4, $4)`, [cuid(), userId, Math.round(steps[i]), date], ); } // ── Per-stage sleep nights (last 7 nights, incl. last night) ── // One SLEEP_DURATION row per stage, all stamped at the same wake instant, // mirroring how a HealthKit night lands (AWAKE / REM / CORE / DEEP, with // the legacy aggregate kept above). The reader sums the stages for the // night's total. Durations target a healthy ~7.5 h with a normal stage // mix (~55% core, ~22% REM, ~18% deep, ~5% awake). Source APPLE_HEALTH // with device_type 'watch' so the night reads as device-tracked rather // than a manual estimate. console.log("Creating per-stage sleep nights (7 nights)..."); const sleepStages: Array<{ stage: string; fraction: number }> = [ { stage: "AWAKE", fraction: 0.05 }, { stage: "REM", fraction: 0.22 }, { stage: "CORE", fraction: 0.55 }, { stage: "DEEP", fraction: 0.18 }, ]; for (let n = 1; n <= 7; n++) { // Wake at ~06:50 with small per-night jitter; total ~7.5 h. const wake = daysAgoAt(n - 1, 6, 40 + Math.floor(Math.random() * 25)); const totalMin = 440 + Math.floor(Math.random() * 40); // 7h20m – 8h for (const { stage, fraction } of sleepStages) { const stageMin = Math.round(totalMin * fraction); await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, sleep_stage, device_type, measured_at, created_at, updated_at) VALUES ($1, $2, 'SLEEP_DURATION', $3, 'minutes', 'APPLE_HEALTH', $4, 'watch', $5, $5, $5)`, [cuid(), userId, stageMin, stage, wake], ); } } // ── Body composition (smart-scale series) ── // A full scale-style breakdown, written every 2–3 days like a real // body-composition scale would record. Every component is DERIVED from // the same-day weight + body-fat series above so the numbers reconcile: // fat mass = weight × bodyFat%, fat-free mass = weight − fat mass, and // the muscle / water / bone / BMI components hang off those so the demo // never contradicts its own headline weight + body-fat tiles. Source // APPLE_HEALTH with device_type 'scale' so they read as device-tracked. // Canonical units (see prisma/schema.prisma + VALUE_RANGES): mass // components in kg, VISCERAL_FAT a 1–12 rating, BODY_MASS_INDEX kg/m². console.log("Creating body composition series..."); const HEIGHT_M = 1.82; for (let i = 0; i < span; i++) { // Roughly every other day, jittered, so the cadence reads organic. if (i % 2 !== 0 && Math.random() > 0.4) continue; const date = daysAgo(days - i); const weightKg = weights[i]; const fatPct = bodyFat[i]; const fatMass = Math.round(weightKg * (fatPct / 100) * 10) / 10; const fatFree = Math.round((weightKg - fatMass) * 10) / 10; // Skeletal muscle is a subset of fat-free mass (~75% of it for a lean // adult male); lean body mass ≈ fat-free mass. const muscleMass = Math.round(fatFree * 0.75 * 10) / 10; const leanMass = fatFree; // Total body water ≈ 73% of fat-free mass (kg of water). const bodyWater = Math.round(fatFree * 0.73 * 10) / 10; const boneMass = Math.round((3.1 + (Math.random() - 0.5) * 0.2) * 10) / 10; // Visceral-fat rating on Withings' 1–12 scale; a healthy ~6, easing // down slightly as the body fat trends down. const visceralFat = Math.round(7 - (i / span) * 1.5); const bmi = Math.round((weightKg / (HEIGHT_M * HEIGHT_M)) * 10) / 10; const composition: Array<{ type: string; value: number; unit: string }> = [ { type: "FAT_MASS", value: fatMass, unit: "kg" }, { type: "FAT_FREE_MASS", value: fatFree, unit: "kg" }, { type: "LEAN_BODY_MASS", value: leanMass, unit: "kg" }, { type: "MUSCLE_MASS", value: muscleMass, unit: "kg" }, { type: "TOTAL_BODY_WATER", value: bodyWater, unit: "kg" }, { type: "BONE_MASS", value: boneMass, unit: "kg" }, { type: "VISCERAL_FAT", value: visceralFat, unit: "rating" }, { type: "BODY_MASS_INDEX", value: bmi, unit: "kg/m²" }, ]; for (const c of composition) { await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, 'APPLE_HEALTH', 'scale', $6, $6, $6)`, [cuid(), userId, c.type, c.value, c.unit, date], ); } } // ── Blood glucose (healthy non-diabetic series) ── // A few readings per day over the full window, every value in a healthy // non-diabetic band: fasting ~85–95 mg/dL, a well-controlled post-meal // peak ~110–135, and a calm bedtime ~95–105. Canonical storage is mg/dL // (schema note on BLOOD_GLUCOSE); the per-reading context lands in the // glucose_context column (FASTING / POSTPRANDIAL / BEDTIME) so the // glucose panel can apply the right target band per reading. console.log("Creating blood glucose series..."); const glucoseReadings: Array<{ context: string; hour: number; base: number; jitter: number; }> = [ { context: "FASTING", hour: 7, base: 89, jitter: 6 }, { context: "POSTPRANDIAL", hour: 13, base: 122, jitter: 12 }, { context: "POSTPRANDIAL", hour: 19, base: 118, jitter: 12 }, { context: "BEDTIME", hour: 22, base: 99, jitter: 6 }, ]; for (let i = 0; i < span; i++) { for (const r of glucoseReadings) { const at = daysAgoAt(days - i, r.hour, Math.floor(Math.random() * 30)); const value = Math.round(r.base + (Math.random() - 0.5) * r.jitter); await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, device_type, measured_at, created_at, updated_at) VALUES ($1, $2, 'BLOOD_GLUCOSE', $3, 'mg/dL', 'APPLE_HEALTH', $4, 'phone', $5, $5, $5)`, [cuid(), userId, value, r.context, at], ); } } // ── Cardio fitness + vitals ─────────────── // The dashboard + insights surface these device-tracked vitals. All // recent (full window, latest point today), all in healthy/aspirational // bands. Canonical units per schema + VALUE_RANGES: HRV ms, // OXYGEN_SATURATION percent, RESPIRATORY_RATE breaths/min, VO2_MAX // mL/(kg·min), ACTIVE_ENERGY_BURNED kcal, WALKING_RUNNING_DISTANCE metres, // FLIGHTS_CLIMBED count. Source APPLE_HEALTH device_type 'watch'. console.log("Creating cardio fitness + vitals series..."); // HRV SDNN: a healthy ~55 ms, gently rising; also write the RMSSD variant. const hrvSdnn = randomWalk( 48, 58, span, 5, VALUE_RANGES.HEART_RATE_VARIABILITY, ); const hrvRmssd = randomWalk(42, 52, span, 5, VALUE_RANGES.HRV_RMSSD); // SpO2: 97–99%. const spo2 = randomWalk(98, 98, span, 0.8, VALUE_RANGES.OXYGEN_SATURATION); // Respiratory rate: 13–16 breaths/min. const respRate = randomWalk( 15, 14, span, 0.8, VALUE_RANGES.RESPIRATORY_RATE, ); // Active energy: ~550 kcal/day. const activeKcal = randomWalk( 480, 600, span, 80, VALUE_RANGES.ACTIVE_ENERGY_BURNED, ); // Walking + running distance (metres): ~6–8 km/day, tracks steps. const distM = randomWalk( 6200, 7800, span, 900, VALUE_RANGES.WALKING_RUNNING_DISTANCE, ); // Flights climbed: ~8–14/day. const flights = randomWalk(9, 13, span, 4, VALUE_RANGES.FLIGHTS_CLIMBED); for (let i = 0; i < span; i++) { const date = daysAgo(days - i); const vitals: Array<{ type: string; value: number; unit: string }> = [ { type: "HEART_RATE_VARIABILITY", value: Math.round(hrvSdnn[i]), unit: "ms", }, { type: "HRV_RMSSD", value: Math.round(hrvRmssd[i]), unit: "ms" }, { type: "OXYGEN_SATURATION", value: Math.round(spo2[i] * 10) / 10, unit: "%", }, { type: "RESPIRATORY_RATE", value: Math.round(respRate[i] * 10) / 10, unit: "count/min", }, { type: "ACTIVE_ENERGY_BURNED", value: Math.round(activeKcal[i]), unit: "kcal", }, { type: "WALKING_RUNNING_DISTANCE", value: Math.round(distM[i]), unit: "m", }, { type: "FLIGHTS_CLIMBED", value: Math.round(flights[i]), unit: "count", }, ]; for (const v of vitals) { await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, 'APPLE_HEALTH', 'watch', $6, $6, $6)`, [cuid(), userId, v.type, v.value, v.unit, date], ); } // VO2 max: only every ~10 days (Apple samples it rarely), ~46–49. if (i % 10 === 0) { const vo2 = Math.round((46 + (i / span) * 3) * 10) / 10; await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at) VALUES ($1, $2, 'VO2_MAX', $3, 'mL/(kg·min)', 'APPLE_HEALTH', 'watch', $4, $4, $4)`, [cuid(), userId, vo2, date], ); } } // ── Recovery / strain / sleep scores (WHOOP-style) ── // The WHOOP-style readiness surface. These are stored as Measurement rows // (type RECOVERY_SCORE / DAY_STRAIN / SLEEP_PERFORMANCE / SLEEP_EFFICIENCY // / SLEEP_CONSISTENCY, unit 'score' or '%') with source WHOOP, distinct // from the COMPUTED engine's own rows. Coherent with the seeded sleep: // well-slept nights pair with high recovery + efficiency, and day-strain // sits in a sustainable band. RECOVERY_SCORE / sleep percentages are // 0–100; DAY_STRAIN is on WHOOP's 0–21 scale. console.log("Creating recovery / strain / sleep scores..."); const recovery = randomWalk(68, 78, span, 8, VALUE_RANGES.RECOVERY_SCORE); const dayStrain = randomWalk(11, 13, span, 2.5, VALUE_RANGES.DAY_STRAIN); const sleepPerf = randomWalk( 82, 90, span, 6, VALUE_RANGES.SLEEP_PERFORMANCE, ); const sleepEff = randomWalk(88, 93, span, 4, VALUE_RANGES.SLEEP_EFFICIENCY); const sleepConsistency = randomWalk( 74, 84, span, 6, VALUE_RANGES.SLEEP_CONSISTENCY, ); for (let i = 0; i < span; i++) { // Anchor scores to the morning wake instant so a night's recovery sits // on the day it belongs to. const at = daysAgoAt(days - i, 7, 0); const scores: Array<{ type: string; value: number; unit: string }> = [ { type: "RECOVERY_SCORE", value: Math.round(recovery[i]), unit: "score", }, { type: "DAY_STRAIN", value: Math.round(dayStrain[i] * 10) / 10, unit: "score", }, { type: "SLEEP_PERFORMANCE", value: Math.round(sleepPerf[i]), unit: "%", }, { type: "SLEEP_EFFICIENCY", value: Math.round(sleepEff[i]), unit: "%" }, { type: "SLEEP_CONSISTENCY", value: Math.round(sleepConsistency[i]), unit: "%", }, ]; for (const s of scores) { await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, 'WHOOP', 'band', $6, $6, $6)`, [cuid(), userId, s.type, s.value, s.unit, at], ); } } // ── Workouts ────────────────────────────── // ~3–4 sessions/week across the window: a mix of running, strength, and // cycling, with the fields the workout tiles render — duration, energy, // avg/max HR, distance (running/cycling). Each workout carries a sparse // HR sample series in WorkoutSamples (the per-workout HR chart) and its // per-workout strain in metadata (WORKOUT_STRAIN lives on the workout row // per the schema note, not as a free-floating Measurement). Distances in // metres, energy in kcal, durations in seconds — matching the model. console.log("Creating workouts..."); const workoutPlan: Array<{ sport: string; durationMin: number; kcal: number; avgHr: number; maxHr: number; distanceM: number | null; strain: number; }> = [ { sport: "running", durationMin: 38, kcal: 420, avgHr: 148, maxHr: 171, distanceM: 6500, strain: 13.4, }, { sport: "strength", durationMin: 52, kcal: 360, avgHr: 118, maxHr: 152, distanceM: null, strain: 10.2, }, { sport: "cycling", durationMin: 65, kcal: 520, avgHr: 134, maxHr: 158, distanceM: 24000, strain: 12.1, }, ]; // Step backwards through the window placing ~2 sessions a week per slot // so the cadence lands around 3–4/week without overlapping. let planIdx = 0; for (let dayOffset = days - 1; dayOffset >= 1; dayOffset -= 2) { // Skip ~25% of slots so the week isn't perfectly regular. if (Math.random() < 0.25) continue; const plan = workoutPlan[planIdx % workoutPlan.length]; planIdx += 1; const startHour = 17 + Math.floor(Math.random() * 3); const startedAt = daysAgoAt( dayOffset, startHour, Math.floor(Math.random() * 50), ); const durationSec = plan.durationMin * 60; const endedAt = new Date(startedAt.getTime() + durationSec * 1000); const workoutId = cuid(); await client.query( `INSERT INTO workouts (id, user_id, sport_type, started_at, ended_at, duration_sec, total_energy_kcal, total_distance_m, avg_heart_rate, max_heart_rate, source, metadata, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'APPLE_HEALTH', $11, $4, $4)`, [ workoutId, userId, plan.sport, startedAt, endedAt, durationSec, plan.kcal, plan.distanceM, plan.avgHr, plan.maxHr, JSON.stringify({ workoutStrain: plan.strain }), ], ); // A sparse HR series — one sample every ~5 minutes across the session, // rising into the working band and easing at the end. The per-workout // HR chart renders straight from this WorkoutSamples blob. const sampleCount = Math.max(2, Math.round(plan.durationMin / 5)); const samples: Array<{ t: string; hr: number }> = []; for (let s = 0; s < sampleCount; s++) { const frac = s / (sampleCount - 1); // Warm up from ~60% to the avg, peak near the max around 70% through. const peakBias = 1 - Math.abs(frac - 0.7) * 0.6; const hr = Math.round( plan.avgHr + (plan.maxHr - plan.avgHr) * peakBias * 0.7, ); const t = new Date(startedAt.getTime() + frac * durationSec * 1000); samples.push({ t: t.toISOString(), hr }); } await client.query( `INSERT INTO workout_samples (id, workout_id, samples, sample_count, created_at) VALUES ($1, $2, $3, $4, NOW())`, [cuid(), workoutId, JSON.stringify(samples), samples.length], ); } // ── Medications ─────────────────────────── console.log("Creating medications..."); // Medication 1: Ramipril (blood pressure) const med1Id = cuid(); await client.query( `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at) VALUES ($1, $2, 'Ramipril', '5mg', true, true, $3, $3)`, [med1Id, userId, daysAgo(120)], ); const sched1Id = cuid(); await client.query( `INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label) VALUES ($1, $2, '08:00', '10:00', 'Morning')`, [sched1Id, med1Id], ); // Medication 2: Vitamin D3 const med2Id = cuid(); await client.query( `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at) VALUES ($1, $2, 'Vitamin D3', '2000 IU', true, true, $3, $3)`, [med2Id, userId, daysAgo(90)], ); const sched2Id = cuid(); await client.query( `INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label) VALUES ($1, $2, '08:00', '10:00', 'Morning')`, [sched2Id, med2Id], ); // Medication 3: Magnesium (evening) const med3Id = cuid(); await client.query( `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at) VALUES ($1, $2, 'Magnesium', '400mg', true, true, $3, $3)`, [med3Id, userId, daysAgo(60)], ); const sched3Id = cuid(); await client.query( `INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label) VALUES ($1, $2, '20:00', '22:00', 'Evening')`, [sched3Id, med3Id], ); // ── Medication Intake Events ────────────── // High compliance with no overdue doses. Historical days (i < days) carry // a taken/skip row at ~95%+ adherence. TODAY is handled separately below // so the dashboard always reads on-track (morning doses taken, the // evening dose left as a not-yet-due pending row — never a miss). console.log("Creating intake events (90 days)..."); for (let i = 0; i < days; i++) { const date = daysAgo(days - i); // Anchor each historical intake's scheduled_for on its med's canonical // Berlin dose-slot for that day (08:00 morning, 20:00 evening) — the // same instant the scheduling engine attributes a take to — so taken // rows are slot-anchored (scheduled_for ≠ taken_at, resolved within the // ±6h radius) and skips land exactly on the slot. Anchoring on the slot // rather than a random morning hour keeps the compliance rollups and the // overdue search reading these past days as resolved. const morningSlotFor = berlinHmAsUtc(8, 0, date); const eveningSlotFor = berlinHmAsUtc(20, 0, date); // Ramipril: 98% compliance (miss ~1 every 50 days) const takeMed1 = Math.random() > 0.02; if (takeMed1) { const takenTime = new Date(morningSlotFor); takenTime.setMinutes( takenTime.getMinutes() + Math.floor(Math.random() * 45) + 1, ); await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`, [cuid(), userId, med1Id, morningSlotFor, takenTime], ); } else { await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at) VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4, $4)`, [cuid(), userId, med1Id, morningSlotFor], ); } // Vitamin D3: 95% compliance const takeMed2 = Math.random() > 0.05; if (takeMed2) { const takenTime = new Date(morningSlotFor); takenTime.setMinutes( takenTime.getMinutes() + Math.floor(5 + Math.random() * 50) + 1, ); await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`, [cuid(), userId, med2Id, morningSlotFor, takenTime], ); } else { await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at) VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4, $4)`, [cuid(), userId, med2Id, morningSlotFor], ); } // Magnesium: only last 60 days, 94% compliance if (i >= 30) { const takeMed3 = Math.random() > 0.06; if (takeMed3) { const takenTime = new Date(eveningSlotFor); takenTime.setMinutes( takenTime.getMinutes() + Math.floor(Math.random() * 60) + 1, ); await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`, [cuid(), userId, med3Id, eveningSlotFor, takenTime], ); } else { await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at) VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4, $4)`, [cuid(), userId, med3Id, eveningSlotFor], ); } } } // ── Today's doses (on-track, no overdue) ── // The dashboard today-tile attributes a taken intake to a computed dose // slot, where the slot's canonical instant is the schedule's window_start // in the user's timezone (08:00 Europe/Berlin for the morning meds). A // taken row only RESOLVES that slot when it is "slot-anchored" // (scheduled_for ≠ taken_at — then a ±6h radius applies); a row whose // scheduled_for equals taken_at to the millisecond is treated as an // ad-hoc take that only resolves a slot within ±60s, so an 08:30 ad-hoc // row leaves the 08:00 slot reading "overdue" while the take floats beside // it (the "2/6 today · overdue" screenshot). We therefore anchor // scheduled_for on the EXACT canonical slot instant (08:00 Berlin) and set // taken_at slightly later (08:30) so the row is slot-anchored and resolves // the morning slot cleanly. Both morning meds taken; evening Magnesium is // a pending row anchored on tonight's 20:00 Berlin slot — its anchor is in // the future until 20:00 local, so the overdue search never fires on it // and the compliance engine excludes a plain pending row from the // denominator. The result reads on-track at any daytime screenshot clock. console.log("Creating today's on-track doses..."); const morningSlot = berlinHmAsUtc(8, 0); // canonical 08:00 slot instant const morningTaken = berlinHmAsUtc(8, 30); // taken inside the window await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`, [cuid(), userId, med1Id, morningSlot, morningTaken], ); await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`, [cuid(), userId, med2Id, morningSlot, morningTaken], ); const eveningSlot = berlinHmAsUtc(20, 0); // canonical 20:00 slot instant await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, auto_missed, source, created_at, updated_at) VALUES ($1, $2, $3, $4, NULL, false, false, 'WEB', $4, $4)`, [cuid(), userId, med3Id, eveningSlot], ); // ── AI-processing consent receipt ───────── // The Coach surface is gated behind an active AI-consent receipt whenever // the deployment resolves a server-managed (operator-key) provider chain. // The read path (`latestActiveReceipt`, src/lib/consent/receipts.ts) // filters only on { user_id, kind, revoked_at IS NULL } and never parses // the artefact, so a single active `ai_full` row — the master grant that // satisfies both the Coach and Insights surfaces — is enough to render the // seeded conversation on the read-only demo with no client-side accept // (which the read-only tenant would reject). The partial unique index // consent_receipts_user_id_kind_active_key allows exactly one active row // per (user, kind); we seed one. artefact is a clearly-labelled demo // placeholder (any non-empty value satisfies the gate). console.log("Creating AI-processing consent receipt..."); await client.query( `INSERT INTO consent_receipts (id, user_id, kind, artefact, signed_at, revoked_at, created_at) VALUES ($1, $2, 'ai_full', $3, NOW(), NULL, NOW())`, [ cuid(), userId, JSON.stringify({ source: "demo-seed", scope: "ai_full" }), ], ); // ── Mood Entries ───────────────────────── console.log("Creating mood entries (90 days)..."); const moodOptions = ["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]; const tagOptions = [ ["productive", "exercise", "well-rested"], ["focused", "social", "creative"], ["tired", "stressed"], ["anxious", "low-energy"], ["exhausted", "pain"], ]; // Mood trend: starts solid (~3.5) and improves to ~4.4 — flat-or-up, so // the score never dips into an alarming band. const moodTrend = randomWalk(3.5, 4.4, span, 0.7); for (let i = 0; i < span; i++) { // Skip ~5% of days (forgot to log) if (Math.random() < 0.05) continue; const date = daysAgo(days - i); const rawScore = Math.round(Math.min(5, Math.max(1, moodTrend[i]))); const mood = moodOptions[5 - rawScore]; // invert: score 5 = SUPER_GUT const tags = tagOptions[5 - rawScore]; const loggedAt = new Date(date); loggedAt.setHours(21, Math.floor(Math.random() * 59), 0); await client.query( `INSERT INTO mood_entries (id, user_id, date, mood, score, tags, source, mood_logged_at, synced_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, 'WEB', $7, $7, $7, $7)`, [ cuid(), userId, formatDate(date), mood, rawScore, JSON.stringify(tags), loggedAt, ], ); } // ── Vorsorge reminders (preventive care) ── // User-created (origin VORSORGE) checklist reminders with no auto-resolve // measurement target. Both anchored to fire comfortably in the future so // the Vorsorge surface shows sensible upcoming items, not overdue ones. console.log("Creating Vorsorge reminders..."); const dentalNextDue = daysAgo(-45); // ~6 weeks out await client.query( `INSERT INTO measurement_reminders (id, user_id, label, interval_days, anchor_date, origin, notify_hour, location, next_due_at, created_at, updated_at) VALUES ($1, $2, 'Dental check-up', 365, $3, 'VORSORGE', 9, 'Dentist', $4, NOW(), NOW())`, [cuid(), userId, daysAgo(320), dentalNextDue], ); const physicalNextDue = daysAgo(-90); // ~3 months out await client.query( `INSERT INTO measurement_reminders (id, user_id, label, interval_days, anchor_date, origin, notify_hour, location, next_due_at, created_at, updated_at) VALUES ($1, $2, 'Annual physical', 365, $3, 'VORSORGE', 9, 'Family doctor', $4, NOW(), NOW())`, [cuid(), userId, daysAgo(275), physicalNextDue], ); // ── Lab panel (biomarkers in healthy ranges) ── // One grouped panel taken ~10 days ago, every analyte comfortably inside // its reference range, so the labs surface reads clean. console.log("Creating lab panel..."); const panelTakenAt = daysAgo(10); const labResults: Array<{ analyte: string; value: number; unit: string; low: number; high: number; }> = [ { analyte: "Total Cholesterol", value: 178, unit: "mg/dL", low: 0, high: 200, }, { analyte: "LDL", value: 98, unit: "mg/dL", low: 0, high: 130 }, { analyte: "HDL", value: 58, unit: "mg/dL", low: 40, high: 100 }, { analyte: "Triglycerides", value: 92, unit: "mg/dL", low: 0, high: 150 }, { analyte: "HbA1c", value: 5.2, unit: "%", low: 4.0, high: 5.6 }, { analyte: "Fasting Glucose", value: 89, unit: "mg/dL", low: 70, high: 99, }, { analyte: "Vitamin D", value: 42, unit: "ng/mL", low: 30, high: 100 }, { analyte: "TSH", value: 1.8, unit: "mIU/L", low: 0.4, high: 4.0 }, { analyte: "Ferritin", value: 120, unit: "ng/mL", low: 30, high: 400 }, ]; for (const lab of labResults) { await client.query( `INSERT INTO lab_results (id, user_id, panel, analyte, value, unit, reference_low, reference_high, taken_at, source, created_at, updated_at) VALUES ($1, $2, 'Annual blood panel', $3, $4, $5, $6, $7, $8, 'MANUAL', NOW(), NOW())`, [ cuid(), userId, lab.analyte, lab.value, lab.unit, lab.low, lab.high, panelTakenAt, ], ); } // ── Resolved illness episode ────────────── // A short cold ~7 weeks ago, fully recovered, so the illness journal shows // a clean "resolved" entry rather than an active sickness. console.log("Creating resolved illness episode..."); await client.query( `INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, created_at, updated_at) VALUES ($1, $2, 'Common cold', 'INFECTION', 'ACUTE', $3, $4, NOW(), NOW())`, [cuid(), userId, daysAgo(49), daysAgo(43)], ); // ── Achievements ───────────────────────── console.log("Creating achievements..."); // Every id below is a real key from the achievement registry // (src/lib/gamification/achievements.ts) so the badge resolves to a real // title/icon rather than an "unknown" placeholder. Only milestones the // seeded history genuinely supports are unlocked (first-entry badges, // logging streaks, in-range BP/pulse/BMI streaks, compliance + miss-free // streaks, the self-context + 7-night sleep badges). The dynamic streak // families use the `-` naming the generator emits. const achievements = [ { id: "weight-first", daysAgo: 89 }, { id: "bp-first", daysAgo: 89 }, { id: "pulse-first", daysAgo: 89 }, { id: "mood-first", daysAgo: 89 }, { id: "self-context-complete", daysAgo: 88 }, { id: "entry-streak-7", daysAgo: 82 }, { id: "mood-streak-7", daysAgo: 82 }, { id: "sleep-log-7", daysAgo: 70 }, { id: "mood-up-7", daysAgo: 68 }, { id: "weight-50", daysAgo: 60 }, { id: "bp-50", daysAgo: 55 }, { id: "entry-streak-30", daysAgo: 50 }, { id: "mood-streak-30", daysAgo: 50 }, { id: "miss-free-7", daysAgo: 48 }, { id: "consistent-month", daysAgo: 45 }, { id: "bp-green-7", daysAgo: 42 }, { id: "pulse-green-7", daysAgo: 40 }, { id: "bmi-green-7", daysAgo: 38 }, { id: "compliance-80-30", daysAgo: 30 }, { id: "miss-free-30", daysAgo: 30 }, { id: "on-time-perfect-7", daysAgo: 28 }, { id: "measurement-weeks-4", daysAgo: 25 }, { id: "bp-green-30", daysAgo: 12 }, { id: "pulse-green-30", daysAgo: 10 }, { id: "miss-free-90", daysAgo: 2 }, ]; for (const ach of achievements) { const unlocked = daysAgo(ach.daysAgo); await client.query( `INSERT INTO user_achievements (id, user_id, achievement_id, unlocked_at, created_at) VALUES ($1, $2, $3, $4, $4)`, [cuid(), userId, ach.id, unlocked], ); } // ── Coach sample conversation ───────────── // A short, encrypted two-turn exchange so the Coach surface shows real // content. Messages use the same AES-256-GCM Bytes codec the app writes // with (encryptToBytes → encrypt() under ENCRYPTION_KEYS), so they decrypt // exactly like a live conversation — no faked / plaintext rows. console.log("Creating Coach sample conversation..."); const convoId = cuid(); const convoStart = daysAgo(3); await client.query( `INSERT INTO coach_conversations (id, user_id, title, created_at, updated_at) VALUES ($1, $2, $3, $4, $4)`, [convoId, userId, "Blood pressure trend", convoStart], ); const coachTurns: Array<{ role: string; content: string; metricSourceJson?: string; }> = [ { role: "user", content: "How has my blood pressure been trending lately?", }, { role: "assistant", content: "Your blood pressure has been settling nicely. Over the last few " + "weeks the morning readings have drifted into the optimal band, and " + "your resting heart rate is sitting in a comfortable range too. " + "Keeping up the steady sleep and daily movement looks like it is " + "paying off — worth staying consistent with the morning measurements " + "so the trend stays easy to read.", metricSourceJson: JSON.stringify({ window: "last30days", metrics: ["bloodPressure", "restingHeartRate", "sleep"], }), }, ]; for (let t = 0; t < coachTurns.length; t++) { const turn = coachTurns[t]; const ts = new Date(convoStart.getTime() + t * 60_000); const encrypted = Buffer.from(encryptToBytes(turn.content)); await client.query( `INSERT INTO coach_messages (id, conversation_id, role, encrypted_content, metric_source_json, provider_type, prompt_version, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ cuid(), convoId, turn.role, encrypted, turn.metricSourceJson ?? null, turn.role === "assistant" ? aiProvider : null, turn.role === "assistant" ? "demo" : null, ts, ], ); } // ── Baked AI insight texts ──────────────── // The public demo has no live AI provider (the seeded key is a // placeholder ciphertext), so the Insights / daily-briefing / per-metric // status surfaces would otherwise render empty or a "connect a provider" // prompt. We bake realistic, grounded English prose straight into the // same cache rows a real generation would have written, so the demo shows // finished AI output that survives every re-seed and never calls out. // // The prose follows the real generators' voice (src/lib/ai/prompts/ // base-system.ts): second person, warm, grounded in the seeded numbers, // autonomy-supporting, never diagnostic, no banned positivity openers. // The numbers quoted below match the seeded targets (weight ~82 kg, // BP settling ~118/76, resting HR ~58, BMI ~24.7, mood improving, // compliance ~96%+). console.log("Creating baked AI insight texts..."); const todayKey = berlinDayKey(); // A real-looking provider/model pair so the read path treats the row as a // genuine assessment (NOT a `model: "timeout-stub"`, which the cache-read // rejects). The text is what renders; provider/model are provenance only. const bakedProvider = "anthropic"; const bakedModel = "claude-3-5-sonnet"; // ── Comprehensive insight + daily briefing ── // Stored on users.insights_cached_text (JSON). The GET read parses it and // returns it as-is; setting insights_cached_at = NOW() keeps it inside the // 24 h freshness window for the dashboard-snapshot briefing path. The shape // is the union of insightResultSchema (summary / findings / correlations / // recommendations / dataQuality / disclaimer / classification) and the // richer optional blocks the dashboard validates (dailyBriefing with // paragraph + signalsOfDay + keyFindings, trendAnnotations). const comprehensiveInsight = { insightType: "comprehensive", summary: "Your numbers have moved the right way across the board this quarter. " + "Blood pressure has settled into the optimal band, your weight is " + "down to a steady place, resting heart rate is low, and your sleep " + "and mood have both been climbing. The consistency in your daily " + "logging is doing a lot of the work here.", classification: "optimal", classificationLabel: "On track", findings: [ { label: "Blood pressure", value: "~118/76 mmHg", assessment: "positive", guideline: "ESH 2023 optimal range", }, { label: "Weight", value: "~82 kg (BMI ~24.7)", assessment: "positive", }, { label: "Resting heart rate", value: "~58 bpm", assessment: "positive", }, { label: "Medication adherence", value: "96%+ over 90 days", assessment: "positive", }, { label: "Mood", value: "trending up to ~4.4 / 5", assessment: "positive", }, ], correlations: [ { factor: "Sleep duration and resting heart rate", effect: "Longer, more consistent nights line up with your lower resting " + "heart rate readings.", confidence: "mittel", }, ], primaryRecommendation: "Keep the morning measurement habit going — the steadier the cadence, " + "the easier your trends are to read.", recommendations: [ { text: "Your morning blood-pressure readings are the clearest signal " + "you have. Keeping them at a consistent time each day will keep " + "the trend easy to interpret.", severity: "suggestion", rationale: { dataWindow: "last30days", comparedTo: "your own 90-day baseline", deviation: "systolic down ~10 mmHg from the start of the window", }, }, { text: "Sleep has been trending toward a healthy 7.5 hours. Protecting " + "that window on busier evenings is worth a little planning.", severity: "info", rationale: { dataWindow: "last30days", comparedTo: "your earlier nights this quarter", deviation: "about 45 minutes longer on average", }, }, ], dataQuality: { coverage: "90 days of daily measurements across every core metric", gaps: [], confidence: "hoch", }, disclaimer: "This is a reasoned observation of your own data, not medical advice " + "or a diagnosis. Discuss any concerns with your doctor.", dailyBriefing: { paragraph: "Good morning. Today's picture is a calm one: your most recent " + "blood pressure sits comfortably in the optimal band, resting heart " + "rate is low, and last night's sleep landed right around your " + "target. Weight is holding at its new steady place and your mood " + "has been on a quiet upward run all week. There is nothing here " + "that needs fixing — the work now is simply keeping the rhythm " + "you have built, logging each morning and protecting your sleep " + "window. Small, consistent days are exactly what these trends are " + "made of.", signalsOfDay: [ { sourceMetric: "bp", tone: "good", headline: "Your latest blood pressure is sitting in the optimal band.", nudge: "Take tomorrow's reading at the same morning time to keep the " + "trend clean.", delta: "↓ ~10 mmHg systolic vs the start of the window", }, { sourceMetric: "sleep", tone: "good", headline: "Last night came in close to your 7.5-hour target.", nudge: "Aim for the same lights-out time tonight.", delta: null, }, { sourceMetric: "resting_hr", tone: "info", headline: "Resting heart rate is steady around 58 bpm.", nudge: "Keep the daily movement going — it shows up here.", delta: null, }, ], keyFindings: [ { tone: "good", headline: "Blood pressure has settled into the optimal range.", detail: "Your 30-day average is well inside the optimal band and lower " + "than where the quarter began.", delta: "↓ ~10 mmHg", sourceWindow: "30d", sourceMetric: "bp", }, { tone: "good", headline: "Weight is holding at a healthy, steady place.", detail: "After trending down earlier in the quarter, your weight has " + "levelled off at a BMI of about 24.7.", delta: null, sourceWindow: "90d", sourceMetric: "weight", }, { tone: "good", headline: "Medication adherence has stayed above 96%.", detail: "You have logged your doses on time across nearly the entire " + "90-day window.", delta: null, sourceWindow: "90d", sourceMetric: "compliance", }, { tone: "info", headline: "Mood has drifted gently upward.", detail: "Your entries have climbed from the mid-threes toward the " + "low-fours over the quarter.", delta: null, sourceWindow: "90d", sourceMetric: "mood", }, ], }, trendAnnotations: { bp: "Your systolic is trending down into the optimal band — a pattern worth keeping.", weight: "Weight has levelled off after an earlier decline — a stable, healthy place to hold.", mood: "Mood has been on a quiet upward run across the quarter.", sleep: "Nights have lengthened toward a steady 7.5 hours over the window.", resting_hr: "Resting heart rate has eased lower as your activity has held up.", }, }; await client.query( `UPDATE users SET insights_cached_text = $2, insights_cached_at = NOW(), insights_snapshot_hash = $3, insights_briefing_reroll_date = $4 WHERE id = $1`, [ userId, JSON.stringify(comprehensiveInsight), // A stable fingerprint. The 24 h cache window keeps the row fresh on // its own; this value just has to be present + non-null so a // hypothetical regeneration would treat the snapshot as known. The // demo provider can never succeed, so the baked text always survives. "demo-baked-comprehensive-snapshot", // Mark today's briefing re-roll as already done so the once-per-day // re-roll path is a no-op (it would fail against the placeholder key). todayKey, ], ); // ── Per-metric status cards ── // Each card is an audit_logs row keyed `insights.-status.` // whose details JSON is { dateKey, locale, text, providerType, model, // tokensUsed, snapshotHash }. The read serves a row only when its dateKey // equals today's Berlin key and the model is not the timeout-stub // sentinel. We stamp today's key + a real model name so every card reads // as a current assessment. `text` is the rendered field. 2-4 sentences // each, grounded in the seeded data, in the base-system advisor voice. const statusCards: Array<{ scope: string; text: string }> = [ { scope: "blood-pressure", text: "Your blood pressure is averaging about 118/76 over the last few " + "weeks — down roughly 10 mmHg systolic from where this quarter " + "started, and now comfortably inside the optimal band. That is a " + "real, earned shift, not day-to-day noise. Keeping the morning " + "readings on a steady schedule will make the trend even easier to " + "follow.", }, { scope: "pulse", text: "Your resting heart rate is sitting around 58 bpm this week, a few " + "beats below your monthly mean and among your lowest readings in " + "the window. That tracks with the steady daily movement you have " + "kept up. Nothing to change here — just keep the rhythm going.", }, { scope: "weight", text: "Your weight has settled at about 82 kg after trending down earlier " + "in the quarter — a stable, healthy place rather than a continuing " + "decline. Holding here is exactly the right goal. Weighing in at a " + "consistent time keeps the reading clean.", }, { scope: "bmi", text: "Your BMI is right around 24.7, near the top of the healthy range " + "and steady over the last month. With your weight holding, this is " + "a good place to maintain. No action needed beyond keeping your " + "current routine.", }, { scope: "mood", text: "Your mood entries have drifted upward over the quarter, from the " + "mid-threes toward the low-fours, and the recent run has been your " + "most positive yet. The consistency of your logging makes that " + "trend trustworthy. Worth noticing what has been going well and " + "leaning into it.", }, { scope: "medication-compliance", text: "Your adherence has stayed above 96% across the last 90 days, with " + "today's morning doses already taken and the evening one not yet " + "due. That is a strong, durable streak. Keeping your reminders " + "where they are should be enough to hold it.", }, { scope: "general", text: "Across the board your numbers are in a good place: blood pressure " + "in the optimal band, weight steady, resting heart rate low, and " + "sleep and mood both trending up. The throughline is your " + "consistency — daily logging is what makes all of this readable. " + "Keep the cadence and the picture stays clear.", }, ]; for (const card of statusCards) { const action = `insights.${card.scope}-status.en`; await client.query( `INSERT INTO audit_logs (id, user_id, action, details, created_at) VALUES ($1, $2, $3, $4, NOW())`, [ cuid(), userId, action, JSON.stringify({ dateKey: todayKey, locale: "en", text: card.text, providerType: bakedProvider, model: bakedModel, tokensUsed: null, snapshotHash: `demo-baked-${card.scope}`, }), ], ); } // ── Period narratives (week + month) ── // insight_narratives rows, one per (period, locale). The read path is pure // stale-while-revalidate: it returns whatever was last written regardless // of age, decrypting encrypted_content with the app's AES-256-GCM codec // (encrypt() → utf8 bytes, mirroring the CoachMessage/narrative helper). // provenance_json carries the labels-only envelope the UI renders. const nowIso = new Date().toISOString(); const narrativeWindowFrom = daysAgo(30).toISOString(); const periodNarratives: Array<{ period: string; text: string; window: { from: string; to: string }; }> = [ { period: "week", text: "This week held the steady line you have been building. Your blood " + "pressure stayed in the optimal band every morning, resting heart " + "rate hovered near its low, and your nights landed close to 7.5 " + "hours. Adherence was perfect, and your mood entries were among " + "the brightest of the month. There is no single thing to fix here " + "— the value is in how repeatable the week was. Carrying the same " + "sleep and movement routine into next week is all it takes to keep " + "the trend intact.", window: { from: daysAgo(7).toISOString(), to: nowIso }, }, { period: "month", text: "Over the past month the gains from earlier in the quarter have " + "become your new normal. Blood pressure has held in the optimal " + "band rather than just dipping into it, weight has stabilised at a " + "healthy point, and resting heart rate has stayed low alongside " + "consistent daily activity. Sleep lengthened and steadied, and " + "your mood trend continued its quiet climb. The month reads less " + "like a change in progress and more like a routine that is paying " + "off — worth protecting exactly as it stands.", window: { from: narrativeWindowFrom, to: nowIso }, }, ]; for (const narrative of periodNarratives) { const encryptedContent = Buffer.from(encrypt(narrative.text), "utf8"); const provenance = { metrics: [ "BLOOD_PRESSURE_SYS", "RESTING_HEART_RATE", "SLEEP_DURATION", "WEIGHT", "MOOD", ], window: narrative.window, pairsTested: 0, fdrQ: 0.1, computedAt: nowIso, }; await client.query( `INSERT INTO insight_narratives (id, user_id, period, locale, date_key, encrypted_content, provenance_json, provider_type, prompt_version, created_at, updated_at) VALUES ($1, $2, $3, 'en', $4, $5, $6, $7, $8, NOW(), NOW())`, [ cuid(), userId, narrative.period, todayKey, encryptedContent, JSON.stringify(provenance), bakedProvider, "demo", ], ); } // ── App Settings ───────────────────────── console.log("Creating app settings..."); await client.query( `INSERT INTO app_settings (id, registration_enabled, default_locale) VALUES ('singleton', false, 'en') ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'`, ); // ── Audit Log (some login entries) ────── console.log("Creating audit log entries..."); for (let i = 0; i < 15; i++) { const loginDate = daysAgo(Math.floor(Math.random() * 30)); await client.query( `INSERT INTO audit_logs (id, user_id, action, details, ip_address, created_at) VALUES ($1, $2, 'auth.login', '{"method":"password"}', '203.0.113.42', $3)`, [cuid(), userId, loginDate], ); } await client.query("COMMIT"); console.log("\nDemo data seeded successfully!"); console.log(` User: demo / demo123demo123`); console.log( ` Measurements: weight, BP, pulse, resting HR, body fat, steps, sleep`, ); console.log( ` Body composition: fat/lean/muscle mass, water, bone, visceral fat, BMI`, ); console.log( ` Blood glucose: ${glucoseReadings.length} readings/day (fasting + post-meal + bedtime)`, ); console.log( ` Cardio + vitals: HRV (SDNN + RMSSD), SpO2, resp. rate, VO2max, active energy, distance, flights`, ); console.log( ` Scores: recovery, day strain, sleep performance/efficiency/consistency`, ); console.log( ` Sleep: 7 per-stage nights (4 stages each) + daily aggregate`, ); console.log(` Medications: 3 (high compliance, today on-track)`); console.log( ` Workouts: ~3-4/week (running, strength, cycling) with HR samples`, ); console.log(` Mood: ~${Math.round(span * 0.95)} entries`); console.log(` Vorsorge reminders: 2 (dental, annual physical)`); console.log(` Lab panel: ${labResults.length} biomarkers`); console.log(` Illness: 1 resolved episode`); console.log(` Coach: 1 conversation (${coachTurns.length} messages)`); console.log( ` Baked AI texts: comprehensive + daily briefing, ${statusCards.length} status cards, ${periodNarratives.length} period narratives (en)`, ); console.log(` Achievements: ${achievements.length}`); } catch (err) { await client.query("ROLLBACK"); console.error("Seed failed:", err); throw err; } finally { client.release(); await pool.end(); } } seed();