Files
HealthLog/scripts/seed-demo.ts
T

1604 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 `<prefix>-<target>` 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.<scope>-status.<locale>`
// 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();