Files
Marc-André Bombeck c96e0e0f78 chore(demo): seed lab readings without a draw label as their panel
A draw label such as "Annual blood panel" is a panel the labs list
would show as one category holding every marker, so the demo list
would stay flat with categories turned on. Without a panel each marker
lands in its catalog category by name, both before and after the boot
backfill links the rows.
2026-11-06 17:54:02 +01:00

4088 lines
158 KiB
TypeScript
Raw Permalink 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, fortnightly waist) — flat-or-improving trends,
* most-recent point from today; blood pressure has one busy stretch two
* to three weeks back (about 144/91 for some ten days) that comes down
* after a ramipril dose increase
* - 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) plus a 14-day Nightscout-style CGM
* stream (a reading every 15 min) so the glucose panel renders fully
* - 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); each
* filed under a category, the evening supplement recording itself
* - Two lab readings the report printed in SI units, stored converted with
* the printed value kept beside them
* - 90 days of mood entries
* - Six mental-wellbeing screeners (WHO-5, PHQ-9, GAD-7) spread over the
* window, item answers encrypted the way the check-in route writes them
* - Vorsorge (preventive-care) reminders — a dental check-up due today and
* an annual physical in three months
* - Two lab panels of biomarkers across two dates (quantitative with
* reference ranges + qualitative "negativ" rows)
* - Two illness episodes — a resolved acute cold with a day-by-day symptom
* curve, and a recurring condition with a resolved spring flare; nothing
* is left open, so the account is not in Rest Mode
* - Cycle tracking opted in: ~5 observed cycles with biphasic basal body
* temperature, period flow, fertile-window mucus/OPK, per-day symptoms,
* and a cached forward prediction
* - Two practitioners, a knee arthroscopy with its operative report and
* discharge letter linked (small generated PDFs), its follow-up visit, the
* blood-pressure visit that raised ramipril, and a check-up booked for
* tomorrow morning
* - An AI-configured Coach with five sample conversations spread from
* today back over a few weeks
* - App settings (registration disabled, English locale) and the recovery
* kit confirmed for the keys the demo runs with
* - A multi-year history before the 90-day window (about four and a half
* years) for the timeline and the day view: blood pressure in bursts with
* real gaps, weekly weight, phone steps, watch resting heart rate / HRV /
* sleep from the third year, yearly blood panels, a resolved chronic
* condition and infections, ramipril with a dose change and a sick-day
* pause, flu shots, visits, life events at day / month / year precision,
* a sealed home location with a holiday period, and stored weather, air
* quality and pollen for every day. The timeline module is switched on.
*
* Every date is relative to "now" so the demo stays fresh on every re-seed.
* What ages is the SEEDED DATA, not the script: the pillar windows keep
* moving after a seed, so about four weeks on, activity (21 of the last
* 28 days) and sleep (14 of 28 nights) fall out of their windows and the
* Health Score narrows to whatever cardiometabolic data is still fresh.
* Since v1.38 that is a labelled partial score rather than a blank card,
* but it is still not the demo at its best — re-seed to restore breadth.
*
* The five areas of health the score can span all have data here on
* purpose: cardiometabolic (BP, glucose, lipids), activity (steps),
* sleep, adiposity (waist) and wellbeing (screeners). Removing any one
* of them does not break the seed; it quietly narrows what the demo's
* score can show.
*
* Usage: npx tsx scripts/seed-demo.ts
* Requires DATABASE_URL env var. The Coach sample conversations additionally
* needs ENCRYPTION_KEYS / ENCRYPTION_KEY (the same key the app runs with).
*/
import { Buffer } from "node:buffer";
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import pg from "pg";
import { encryptToBytes } from "../src/lib/ai/coach/bytes-codec";
import {
encrypt,
encryptBytes,
getActiveKeyId,
getConfiguredKeyIds,
getKeyFingerprint,
} from "../src/lib/crypto";
import {
applicationKitKeys,
backupKitKeys,
tokenKitKeys,
} from "../src/lib/recovery/kit-rules";
import { ENVIRONMENT_LOCATION_AAD } from "../src/lib/crypto/encrypted-columns";
import { withBriefingGeneratedAt } from "../src/lib/insights/briefing-generated-at";
import { dailyStatsExternalId } from "../src/lib/measurements/apple-health-mapping";
import { sealInsightsCachedText } from "../src/lib/insights/cached-text-cipher";
import {
DEFAULT_CHART_OVERLAY_PREFS,
DEFAULT_DASHBOARD_LAYOUT,
serializeDashboardLayout,
} from "../src/lib/dashboard-layout";
import {
INSTRUMENT_MEASUREMENT_TYPE,
scoreTotal,
severityBand,
} from "../src/lib/mental-health/instruments";
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);
}
// Every timestamp column here is `timestamp without time zone` holding UTC,
// which is how Prisma reads it. node-postgres serialises a Date in the host's
// local zone by default and Postgres drops the offset on the cast, so a seed
// run from a machine on Berlin time stored every instant two hours in the
// future (today's Coach thread stamped after "now", the CGM stream running
// into the evening). Sending Dates as UTC makes the result independent of
// the seed host's clock zone.
pg.defaults.parseInputDatesAsUTC = true;
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 },
// How hard each step pulls back towards the target. The default lets a
// series wander; blood pressure needs a firmer pull, or a seed could end
// several mmHg above the settled band its stored texts describe.
reversion = 0.03,
): number[] {
const values: number[] = [start];
for (let i = 1; i < days; i++) {
const prev = values[i - 1];
const drift = (target - prev) * 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;
}
// A one-page PDF carrying a few lines of Helvetica text, built by hand so the
// seed needs no PDF library. The byte offsets in the cross-reference table are
// computed, so viewers and the thumbnail renderer open it as a normal file.
function simplePdf(lines: string[]): Buffer {
const esc = (t: string) => t.replace(/[\\()]/g, (c) => `\\${c}`);
const text = lines
.map((line, i) =>
i === 0
? `BT /F1 16 Tf 56 780 Td (${esc(line)}) Tj ET`
: `BT /F1 11 Tf 56 ${760 - i * 18} Td (${esc(line)}) Tj ET`,
)
.join("\n");
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
`<< /Length ${Buffer.byteLength(text, "latin1")} >>\nstream\n${text}\nendstream`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
];
let body = "%PDF-1.4\n";
const offsets: number[] = [];
objects.forEach((obj, i) => {
offsets.push(Buffer.byteLength(body, "latin1"));
body += `${i + 1} 0 obj\n${obj}\nendobj\n`;
});
const xrefAt = Buffer.byteLength(body, "latin1");
body += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (const off of offsets)
body += `${String(off).padStart(10, "0")} 00000 n \n`;
body += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefAt}\n%%EOF\n`;
return Buffer.from(body, "latin1");
}
// Pin a series' last point (today) to the median of the four weeks before it.
// A free walk can end on a tail value, and the vitals check holds today's
// reading against exactly that personal band: a demo whose oxygen saturation
// happened to land low read "below your range" on the start page. The median
// sits inside the band by construction, so today reads as an ordinary day.
function settleLatest(values: number[], lookback = 28): void {
if (values.length < 2) return;
const prior = values
.slice(Math.max(0, values.length - 1 - lookback), values.length - 1)
.sort((a, b) => a - b);
const mid = Math.floor(prior.length / 2);
const median =
prior.length % 2 === 1 ? prior[mid] : (prior[mid - 1] + prior[mid]) / 2;
values[values.length - 1] = Math.round(median * 10) / 10;
}
// ── Multi-year history ───────────────────────
// The timeline and the day view read years, not weeks. Everything before the
// 90-day window is written by `seedHistory` below; these anchors place its
// story (all in days before "now") so the sections that share a date agree.
/** How far back the record reaches: about four and a half years. */
const HISTORY_DAYS = 1650;
/** Ramipril 2.5 mg starts after a blood-pressure work-up. */
const RAMIPRIL_START_DAYS_AGO = 1130;
/** Ramipril raised to 5 mg when the readings stay above target. */
const RAMIPRIL_DOSE_UP_DAYS_AGO = 760;
/** A sick-day pause of ramipril during a stomach bug. */
const RAMIPRIL_PAUSE = { from: 425, to: 416 };
/** Vitamin D3 from the winter before last. */
const VITAMIN_D_START_DAYS_AGO = 400;
/** A daughter is born; sleep and the blood-pressure habit both suffer. */
const DAUGHTER_BORN_DAYS_AGO = 650;
/** A watch arrives at the start of the record's third year. */
const WATCH_FROM_DAYS_AGO = 912;
/** One summer holiday away from home. */
const HOLIDAY = { from: 470, to: 457 };
/** The demo's home: a neutral German city, coarse (one decimal). */
const DEMO_HOME = { lat: 48.0, lon: 7.8, label: "Freiburg" };
/** The holiday location, coarse; its label is never shown on a surface. */
const DEMO_HOLIDAY_PLACE = { lat: 54.1, lon: 12.1, label: "Holiday" };
/**
* Piecewise-linear target through `[daysAgo, value]` knots, oldest first.
* Outside the knots the nearest end holds.
*/
function knotValue(knots: Array<[number, number]>, n: number): number {
if (n >= knots[0][0]) return knots[0][1];
for (let i = 1; i < knots.length; i++) {
const [d0, v0] = knots[i - 1];
const [d1, v1] = knots[i];
if (n <= d0 && n >= d1) {
const f = d0 === d1 ? 0 : (d0 - n) / (d0 - d1);
return v0 + (v1 - v0) * f;
}
}
return knots[knots.length - 1][1];
}
/** Uniform noise in `[-amp, amp]`. */
function jitter(amp: number): number {
return (Math.random() * 2 - 1) * amp;
}
/** The calendar quarter of a Berlin day key, e.g. `2025-Q3`. */
function quarterOf(dayKey: string): string {
const month = Number(dayKey.slice(5, 7));
return `${dayKey.slice(0, 4)}-Q${Math.floor((month - 1) / 3) + 1}`;
}
/** Day of the year (1–366) of a `YYYY-MM-DD` key. */
function dayOfYear(dayKey: string): number {
const d = Date.UTC(
Number(dayKey.slice(0, 4)),
Number(dayKey.slice(5, 7)) - 1,
Number(dayKey.slice(8, 10)),
);
return (
Math.floor((d - Date.UTC(Number(dayKey.slice(0, 4)), 0, 1)) / 864e5) + 1
);
}
/** Seal a coarse location the way `sealLocation` does in the app. */
function sealDemoLocation(loc: {
lat: number;
lon: number;
label: string;
}): Buffer {
return encryptBytes(
Buffer.from(
JSON.stringify({ lat: loc.lat, lon: loc.lon, label: loc.label }),
"utf8",
),
ENVIRONMENT_LOCATION_AAD,
);
}
/**
* Multi-row INSERT in chunks, so a few thousand history rows do not cost a
* round trip each. `rows` are positional, in `columns` order.
*/
async function bulkInsert(
client: pg.PoolClient,
table: string,
columns: string[],
rows: unknown[][],
chunk = 500,
): Promise<void> {
for (let start = 0; start < rows.length; start += chunk) {
const slice = rows.slice(start, start + chunk);
const params: unknown[] = [];
const tuples = slice.map((row) => {
const marks = row.map((value) => {
params.push(value);
return `$${params.length}`;
});
return `(${marks.join(", ")})`;
});
await client.query(
`INSERT INTO ${table} (${columns.join(", ")}) VALUES ${tuples.join(", ")}`,
params,
);
}
}
/**
* A busy stretch inside the 90-day window, in days before "now": blood
* pressure climbs over two days to about 144/91 and stays there for some ten
* days, pulse rises with it and the mood dips. At the family practice on
* `doseUp` ramipril goes from 5 to 10 mg, and over the following days the
* readings come back down to the settled band. Without it every short-window
* series was flat noise around its target, and on a blood-pressure axis that
* has to fit both systolic and diastolic, even a 15 mmHg swing read as a flat
* line. It ends a few days before today, so the latest readings, the tiles and
* the stored texts describe a settled, optimal band again.
*
* It is also no longer than this on purpose. The personal usual range is the
* median ± 3 MAD of the last 30 days: once the stretch fills about half of that
* window, the median lands on the stretch and the band reads something like
* 87-181 mmHg. Kept to this length the band stays around 107-132.
*/
const BUSY_STRETCH = { from: 21, peak: 19, doseUp: 11, settled: 6 } as const;
/**
* Systolic, diastolic and pulse on top of the settled band at the stretch's
* height. The chart pads its axis and rounds it to even ticks: with systolic
* readings up to about 148 the 30-day axis runs 60 to 160 in steps of 20, one
* mmHg more and it jumps to 50 to 200 in steps of 50, which flattens the very
* rise the stretch is there to show. So the lift stops short of that.
*/
const BUSY_STRETCH_LIFT = { sys: 25, dia: 15, pulse: 6 } as const;
/** Ramipril dose after the stretch, in mg (from 5 mg before it). */
const RAMIPRIL_STRETCH_DOSE = 10;
/**
* 0 outside the busy stretch, 1 while it holds: a smooth climb from `from` to
* `peak`, level until the dose change on `doseUp`, a smooth fall to `settled`.
*/
function busyStretch(daysBefore: number): number {
const { from, peak, doseUp, settled } = BUSY_STRETCH;
const smooth = (t: number) => t * t * (3 - 2 * t);
if (daysBefore >= from || daysBefore <= settled) return 0;
if (daysBefore > peak) return smooth((from - daysBefore) / (from - peak));
if (daysBefore >= doseUp) return 1;
return smooth((daysBefore - settled) / (doseUp - settled));
}
/** Pollen season bump: 0 outside `[from, to]`, peaking at `peak` mid-season. */
function pollenSeason(
doy: number,
from: number,
to: number,
peak: number,
): number {
if (doy < from || doy > to) return 0;
const phase = (doy - from) / (to - from);
return peak * Math.sin(Math.PI * phase) ** 2;
}
/** Hours of daylight at latitude `lat` on day-of-year `doy`. */
function daylightHours(lat: number, doy: number): number {
const rad = Math.PI / 180;
const decl = 23.44 * Math.sin(rad * (360 / 365) * (284 + doy));
const x = -Math.tan(lat * rad) * Math.tan(decl * rad);
const ha = Math.acos(Math.min(1, Math.max(-1, x))) / rad;
return (2 * ha) / 15;
}
// ── Main ─────────────────────────────────────────
/**
* Seed the demo tenant. Exported so an integration test can run the real
* seed and read its output back through the app's own readers.
*/
export 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 insight_status_caches");
await client.query("DELETE FROM illness_symptom_links");
await client.query("DELETE FROM illness_day_logs");
await client.query("DELETE FROM illness_episodes");
await client.query("DELETE FROM cycle_symptom_links");
await client.query("DELETE FROM cycle_day_logs");
await client.query("DELETE FROM cycle_predictions");
await client.query("DELETE FROM menstrual_cycles");
await client.query("DELETE FROM cycle_profiles");
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,
],
);
// ── Dashboard showcase layout ─────────────
// One row of six tiles (weight, the two blood-pressure values, resting
// heart rate, HRV and mood) and the weight chart right below it, so the
// first screen of the public demo shows the summary, the tiles and the
// start of a chart together. Below it only the measurement charts: the
// mood and medication cards, achievements, recent workouts and check-ups
// stay one switch away in the layout settings, their data seeded either
// way. Weight, BMI and blood pressure open on 90 days, resting heart rate
// on 30 with its 7-day trend and trend line, so each chart shows a shape
// rather than a flat week. Writing the layout verbatim keeps the demo's
// home page stable across default tweaks.
const SHOWCASE_TILE_IDS = new Set(["weight", "bp", "pulse", "hrv", "mood"]);
const HIDDEN_CARD_IDS = new Set([
"mood",
"medications",
"achievements",
"recentWorkouts",
"vorsorge",
]);
const demoDashboardLayout = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => ({
...w,
visible: w.visible && !HIDDEN_CARD_IDS.has(w.id),
tileVisible: SHOWCASE_TILE_IDS.has(w.id),
})),
chartOverlayPrefs: {
weight: { ...DEFAULT_CHART_OVERLAY_PREFS, rangePoints: 90 },
bmi: { ...DEFAULT_CHART_OVERLAY_PREFS, rangePoints: 90 },
bp: { ...DEFAULT_CHART_OVERLAY_PREFS, rangePoints: 90 },
// The resting heart rate chart is the dashboard's `pulse` chart.
pulse: {
...DEFAULT_CHART_OVERLAY_PREFS,
rangePoints: 30,
showTrendIndicator: true,
showTrendArrow: true,
},
},
});
await client.query(
`UPDATE users SET dashboard_widgets_json = $1 WHERE id = $2`,
[JSON.stringify(demoDashboardLayout), userId],
);
// ── 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). The day-
// to-day noise is kept small, so the 7-day trend over the busy stretch
// below draws an arc rather than jitter.
const sysBP = randomWalk(
128,
118,
span,
1.2,
VALUE_RANGES.BLOOD_PRESSURE_SYS,
0.1,
);
// Diastolic BP: 82 → settled ~76 (clamped)
const diaBP = randomWalk(
82,
76,
span,
1,
VALUE_RANGES.BLOOD_PRESSURE_DIA,
0.1,
);
// 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 → ~7h40m.
// Kept close to the ~7.5 h per-stage nights below, which the start page
// holds against this trailing average: a looser walk drifted to almost
// nine hours and last night read as "1h 4m less than usual".
const sleepMin = randomWalk(
420,
460,
span,
12,
VALUE_RANGES.SLEEP_DURATION,
);
// Steps: 6500 → a solid ~9000
const steps = randomWalk(6500, 9000, span, 1500);
// The busy stretch lifts blood pressure and pulse, and weekends read a
// touch higher than weekdays, as home readings tend to.
for (let i = 0; i < span; i++) {
const before = days - i;
const weekday = daysAgo(before).getDay();
const weekend = weekday === 0 || weekday === 6 ? 1 : 0;
const busy = busyStretch(before);
const lift = (
series: number[],
by: number,
range: { min: number; max: number },
) => {
series[i] = Math.min(
range.max,
Math.max(range.min, Math.round((series[i] + by) * 10) / 10),
);
};
// A little reading-to-reading scatter on top of the slow walk, as a
// cuff has; the 7-day mean averages it out.
lift(
sysBP,
busy * BUSY_STRETCH_LIFT.sys + weekend * 1 + (Math.random() - 0.5) * 3,
VALUE_RANGES.BLOOD_PRESSURE_SYS,
);
lift(
diaBP,
busy * BUSY_STRETCH_LIFT.dia +
weekend * 0.5 +
(Math.random() - 0.5) * 2,
VALUE_RANGES.BLOOD_PRESSURE_DIA,
);
lift(pulse, busy * BUSY_STRETCH_LIFT.pulse, VALUE_RANGES.PULSE);
}
for (const series of [weights, restingHr]) {
settleLatest(series);
}
// Today's reading settles on the days after the busy stretch only, so it
// describes the band the series came back to.
for (const series of [sysBP, diaBP, pulse]) {
settleLatest(series, BUSY_STRETCH.settled);
}
// Each series carries the source that writes it on a real account, the
// way the integration writes it: the Withings scale and cuff (weight,
// body fat, blood pressure with its pulse) land as WITHINGS with no
// device type (`src/lib/withings/sync.ts`), the watch's resting heart
// rate, sleep and steps as APPLE_HEALTH. The list badges every row that
// is not MANUAL, and Settings > Sources names Withings for these
// metrics, so a hand-entered tag here left the demo's newest weight and
// blood-pressure rows without a source while the ladder named one. The
// waist stays MANUAL: it is a tape measure, and nothing syncs it.
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', 'WITHINGS', $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', 'WITHINGS', $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', 'WITHINGS', $4, $4, $4)`,
[cuid(), userId, Math.round(diaBP[i]), date],
);
// Spot pulse (daily), the heart pulse the cuff reports with each
// reading.
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', 'WITHINGS', $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, device_type, measured_at, created_at, updated_at)
VALUES ($1, $2, 'RESTING_HEART_RATE', $3, 'bpm', 'APPLE_HEALTH', 'watch', $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, '%', 'WITHINGS', $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, device_type, measured_at, created_at, updated_at)
VALUES ($1, $2, 'SLEEP_DURATION', $3, 'minutes', 'APPLE_HEALTH', 'watch', $4, $4, $4)`,
[cuid(), userId, Math.round(sleepMin[i]), date],
);
}
// Steps (daily), as the one daily total the iOS app writes per day
// under its `stats:` key, so the legacy-step consolidation leaves it be.
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, external_id, measured_at, created_at, updated_at)
VALUES ($1, $2, 'ACTIVITY_STEPS', $3, 'steps', 'APPLE_HEALTH', 'watch', $4, $5, $5, $5)`,
[
cuid(),
userId,
Math.round(steps[i]),
dailyStatsExternalId(
"HKQuantityTypeIdentifierStepCount",
berlinDayKey(date),
),
date,
],
);
// Waist circumference (fortnightly, plus today). The Health Score's
// adiposity pillar scores waist-to-height against NICE 2022's 0.5,
// and it is the ONLY route into that area of health — weight and
// body fat feed the trends, never the score. Without a waist row
// the demo account has no adiposity standing at all, so the score
// rests on three areas instead of five. 96 cm down to 88 cm over
// the window is 0.527 → 0.484 at the demo's 182 cm: the ratio
// crosses the threshold inside the seeded span, which is what
// makes the pillar worth looking at rather than flat green.
const daysBack = days - i;
if (daysBack % 14 === 0) {
const waistCm = 96 - (8 * i) / (span - 1);
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'WAIST_CIRCUMFERENCE', $3, 'cm', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, Math.round(waistCm * 10) / 10, date],
);
}
}
// ── Mental-wellbeing screeners ────────────
// WHO-5, PHQ-9 and GAD-7 over the same 90-day window. The score's
// wellbeing pillar reads the FRESHEST completed screener, so the last
// one written decides what that area of health says; the earlier ones
// give the history view something to draw. Totals sit in the calm
// bands on purpose — a demo is not the place to model a person in
// distress, and item 9 is answered zero throughout, which also keeps
// the pillar out of its crisis-signposting arm.
//
// Item answers are encrypted the way the check-in route writes them
// (`{ items, schema: 1 }` through the shared Bytes codec), and each
// administration gets its COMPUTED projection measurement so the
// trend chart and the assessment history agree.
console.log("Creating mental-wellbeing screeners...");
const screeners: Array<{
instrument: "WHO5" | "PHQ9" | "GAD7";
items: number[];
daysAgo: number;
}> = [
{ instrument: "WHO5", items: [3, 3, 3, 2, 3], daysAgo: 76 },
{ instrument: "PHQ9", items: [1, 1, 0, 1, 0, 0, 1, 0, 0], daysAgo: 62 },
{ instrument: "GAD7", items: [1, 1, 0, 1, 0, 0, 0], daysAgo: 48 },
{ instrument: "WHO5", items: [4, 3, 4, 3, 3], daysAgo: 34 },
{ instrument: "PHQ9", items: [0, 1, 0, 0, 0, 0, 1, 0, 0], daysAgo: 20 },
{ instrument: "WHO5", items: [4, 4, 4, 3, 4], daysAgo: 6 },
];
for (const screener of screeners) {
const total = scoreTotal(screener.instrument, screener.items);
const takenAt = daysAgoAt(screener.daysAgo, 20, 15);
const assessmentId = cuid();
await client.query(
`INSERT INTO mental_health_assessments
(id, user_id, instrument, locale, version, responses_encrypted, total_score, severity_band,
item9_flagged, taken_at, tz, source, created_at, updated_at)
VALUES ($1, $2, $3, 'en', 'standard', $4, $5, $6, false, $7, 'Europe/Berlin', 'WEB', $7, $7)`,
[
assessmentId,
userId,
screener.instrument,
Buffer.from(
encryptToBytes(
JSON.stringify({ items: screener.items, schema: 1 }),
),
),
total,
severityBand(screener.instrument, total),
takenAt,
],
);
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, external_id, measured_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, 'score', 'COMPUTED', $5, $6, $6, $6)`,
[
cuid(),
userId,
INSTRUMENT_MEASUREMENT_TYPE[screener.instrument],
total,
`assessment:${assessmentId}`,
takenAt,
],
);
}
// ── 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. The
// components the Withings scale measures arrive as WITHINGS, the way its
// sync writes them; lean body mass and BMI, which Withings does not
// report but its app writes to Apple Health, arrive as APPLE_HEALTH with
// device_type 'scale'. HealthKit has no fat-mass, muscle, water, bone or
// visceral-fat type, so none of those can come from Apple Health.
// 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;
source: "WITHINGS" | "APPLE_HEALTH";
}> = [
{ type: "FAT_MASS", value: fatMass, unit: "kg", source: "WITHINGS" },
{
type: "FAT_FREE_MASS",
value: fatFree,
unit: "kg",
source: "WITHINGS",
},
{
type: "LEAN_BODY_MASS",
value: leanMass,
unit: "kg",
source: "APPLE_HEALTH",
},
{
type: "MUSCLE_MASS",
value: muscleMass,
unit: "kg",
source: "WITHINGS",
},
{
type: "TOTAL_BODY_WATER",
value: bodyWater,
unit: "kg",
source: "WITHINGS",
},
{ type: "BONE_MASS", value: boneMass, unit: "kg", source: "WITHINGS" },
{
type: "VISCERAL_FAT",
value: visceralFat,
unit: "rating",
source: "WITHINGS",
},
{
type: "BODY_MASS_INDEX",
value: bmi,
unit: "kg/m²",
source: "APPLE_HEALTH",
},
];
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, $6, $7, $8, $8, $8)`,
[
cuid(),
userId,
c.type,
c.value,
c.unit,
c.source,
c.source === "APPLE_HEALTH" ? "scale" : null,
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 },
];
// Glucose stops at yesterday. The vitals check compares a day's mean
// against the band of full days, so a part day seeded up to the current
// hour (fasting and breakfast only) read "below your range", and seeding
// the whole day put the afternoon in the future. Yesterday's full day is
// what the start page then reads.
for (let i = 0; i < span - 1; 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],
);
}
}
// ── Continuous glucose (Nightscout-style CGM stream) ──
// The clinical glucose panel above gives the spot fasting / post-meal /
// bedtime contexts; a real CGM additionally streams a reading every few
// minutes. Seed a dense, realistic 24 h-cycle stream for the most recent
// 14 days (one sample every 15 min) so the glucose feature's CGM / time-
// in-range surfaces render fully. Values follow a healthy circadian curve:
// a calm overnight ~90, a dawn rise, gentle post-meal bumps at ~8/13/19h
// that settle back inside range — every reading stays in the non-diabetic
// band (70–140 mg/dL). Source NIGHTSCOUT with a stable external_id so the
// (userId, type, source, externalId) immutable key makes the stream
// idempotent across re-seeds (the real Nightscout sync's dedup key). The
// measurements_glucose_context_requires_type CHECK forces a non-NULL
// glucose_context on every BLOOD_GLUCOSE row, so each CGM sample is tagged
// RANDOM (a free-running sensor reading has no meal context).
console.log("Creating continuous glucose (CGM) stream...");
const cgmDays = 14;
const cgmStepMin = 15;
// The stream ends with yesterday, for the reason given above the spot
// readings.
for (let d = cgmDays; d >= 1; d--) {
for (let minute = 0; minute < 24 * 60; minute += cgmStepMin) {
const hour = minute / 60;
// Baseline overnight ~90; dawn phenomenon lifts it slightly toward
// morning. Meal bumps are smooth gaussians centred on 8/13/19h.
const dawn = hour >= 4 && hour <= 8 ? (hour - 4) * 2.5 : 0;
const meal = (centre: number, peak: number, width: number) =>
peak * Math.exp(-((hour - centre) ** 2) / (2 * width * width));
const base =
90 + dawn + meal(8, 28, 1.1) + meal(13, 34, 1.3) + meal(19, 30, 1.3);
const value = Math.round(
Math.min(140, Math.max(72, base + (Math.random() - 0.5) * 6)),
);
const at = daysAgoAt(d, Math.floor(hour), minute % 60);
const externalId = `cgm-${formatDate(at)}-${minute}`;
// The measurements_glucose_context_requires_type CHECK requires a
// non-NULL glucose_context on every BLOOD_GLUCOSE row. A free-running
// CGM sample carries no meal context, so RANDOM ("any time,
// non-fasting, non-post-meal") is the correct enum value.
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, external_id, measured_at, created_at, updated_at)
VALUES ($1, $2, 'BLOOD_GLUCOSE', $3, 'mg/dL', 'NIGHTSCOUT', 'RANDOM', $4, $5, $5, $5)`,
[cuid(), userId, value, externalId, 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 (const series of [hrvSdnn, hrvRmssd, spo2, respRate]) {
settleLatest(series);
}
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],
);
}
}
// ── Readiness and health-score history ────
// The day view reads readiness from the COMPUTED `RECOVERY_SCORE` row the
// nightly job files for the night that ended (noon UTC of the day before
// the wake morning, `recovery:<day>`), and the health score from the
// `health_score_records` row of each local day. Neither job reaches back,
// so a fresh seed carried both for today at most; mint the history the
// jobs would have written over the window.
console.log("Creating readiness and health-score history...");
const shiftKey = (key: string, by: number) =>
new Date(Date.parse(`${key}T12:00:00Z`) + by * 86_400_000)
.toISOString()
.slice(0, 10);
const readinessRows: unknown[][] = [];
for (let i = 1; i < span; i++) {
const wakeKey = berlinDayKey(daysAgoAt(days - i, 7, 0));
const endedKey = shiftKey(wakeKey, -1);
const at = new Date(`${endedKey}T12:00:00.000Z`);
const value = Math.round(
Math.min(100, Math.max(0, recovery[i] * 0.7 + 20 + jitter(4))),
);
readinessRows.push([
cuid(),
userId,
"RECOVERY_SCORE",
value,
"score",
"COMPUTED",
`recovery:${endedKey}`,
at,
at,
at,
]);
}
await bulkInsert(
client,
"measurements",
[
"id",
"user_id",
"type",
"value",
"unit",
"source",
"external_id",
"measured_at",
"created_at",
"updated_at",
],
readinessRows,
);
const PILLAR_BASE: Record<string, number> = {
BLOOD_PRESSURE: 86,
GLYCAEMIA: 98,
ACTIVITY: 95,
SLEEP: 76,
ADIPOSITY: 97,
WELLBEING: 76,
LIPIDS: 100,
};
const composites = randomWalk(87, 90, 60, 3, { min: 80, max: 96 });
const scoreRows: unknown[][] = [];
for (let n = 60; n >= 1; n--) {
const pillars = Object.fromEntries(
Object.entries(PILLAR_BASE).map(([k, v]) => [
k,
Math.round(Math.min(100, Math.max(40, v + jitter(5)))),
]),
);
const at = daysAgoAt(n, 4, 50);
scoreRows.push([
cuid(),
userId,
berlinDayKey(at),
"Europe/Berlin",
Math.round(composites[60 - n]),
"green",
4,
Object.keys(PILLAR_BASE),
JSON.stringify(pillars),
createHash("sha256").update(`demo-health-score:${n}`).digest("hex"),
0,
at,
at,
]);
}
await bulkInsert(
client,
"health_score_records",
[
"id",
"user_id",
"day_key",
"timezone",
"composite",
"band",
"score_version",
"composition",
"pillar_scores",
"input_fingerprint",
"config_version",
"computed_at",
"created_at",
],
scoreRows,
);
// ── 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). Started about three years
// back at 2.5 mg, raised to 5 mg a year later, and to 10 mg during the
// busy stretch inside the 90-day window; the multi-year history section
// below writes the dose changes, a short sick-day pause and the intakes
// before the 90-day window. `starts_on` is the stated start the timeline
// lane draws from.
const med1Id = cuid();
await client.query(
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, starts_on, created_at, updated_at)
VALUES ($1, $2, 'Ramipril', $5, true, true, $3, $4, $4)`,
[
med1Id,
userId,
formatDate(daysAgo(RAMIPRIL_START_DAYS_AGO)),
daysAgo(RAMIPRIL_START_DAYS_AGO),
`${RAMIPRIL_STRETCH_DOSE}mg`,
],
);
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, starts_on, created_at, updated_at)
VALUES ($1, $2, 'Vitamin D3', '2000 IU', true, true, $3, $4, $4)`,
[
med2Id,
userId,
formatDate(daysAgo(VITAMIN_D_START_DAYS_AGO)),
daysAgo(VITAMIN_D_START_DAYS_AGO),
],
);
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). A supplement taken reliably every
// night, so it records itself: `auto_mark_taken` is on from its first day
// and the intake-auto-take job fills each evening slot once 20:00 has
// passed. Its history below is written the way that job writes it.
const med3Id = cuid();
await client.query(
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, starts_on, auto_mark_taken, auto_mark_taken_since, created_at, updated_at)
VALUES ($1, $2, 'Magnesium', '400mg', true, true, $3, true, $4, $5, $5)`,
[med3Id, userId, formatDate(daysAgo(60)), daysAgo(60), 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],
);
// The category each medication is filed under, so the list groups the
// blood-pressure prescription apart from the two supplements.
for (const [medicationId, category] of [
[med1Id, "BLOOD_PRESSURE"],
[med2Id, "VITAMIN"],
[med3Id, "SUPPLEMENT"],
] as const) {
await client.query(
`INSERT INTO medication_categories (medication_id, category, updated_at)
VALUES ($1, $2, NOW())`,
[medicationId, category],
);
}
// ── 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 the last 60 days, recorded automatically at the
// slot instant, the shape the intake-auto-take job writes.
if (i >= 30) {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, auto_recorded, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $4, false, true, 'REMINDER', $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);
// The busy stretch costs about a point of mood at its height.
const moodScore = moodTrend[i] - 1.2 * busyStretch(days - i);
const rawScore = Math.round(Math.min(5, Math.max(1, moodScore)));
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);
// Today's evening entry has not happened yet when the seed runs
// earlier in the day; it is logged a few minutes ago instead.
if (loggedAt.getTime() > Date.now()) {
loggedAt.setTime(Date.now() - 5 * 60_000);
}
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. The dental check-up falls due this morning, so the
// dashboard's "worth a look" carries it beside tomorrow's appointment; the
// annual physical sits comfortably in the future.
console.log("Creating Vorsorge reminders...");
const dentalNextDue = berlinHmAsUtc(9, 0); // due today, 09:00 local
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(365), 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 panels (biomarkers across two dates) ──
// Two grouped panels so the labs list / detail / sort / delete all have
// content: a recent annual blood panel (~10 days ago) and an older one
// (~7 months ago) so the per-analyte trend has at least two points, plus a
// couple of qualitative rows ("negativ" / "nicht nachweisbar") on the
// recent panel. Exactly one of value / value_text is set per row per the
// schema's quantitative-vs-qualitative invariant. Numeric analytes sit
// comfortably inside their reference range; the older panel runs slightly
// higher so the improvement reads as real, earned movement.
console.log("Creating lab panels...");
type QuantLab = {
analyte: string;
value: number;
unit: string;
low: number;
high: number;
/**
* What the report printed when it used another unit than the marker's.
* The stored value is the conversion; the printed pair is kept beside
* it, as the lab write path does, so the list shows "converted from".
*/
printed?: { value: number; unit: string };
};
type QualLab = { analyte: string; valueText: string; unit: string };
const recentPanelAt = daysAgo(10);
const recentQuant: QuantLab[] = [
{
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 },
// Both printed in SI units by the lab: 4.94 mmol/L is 89 mg/dL
// (factor 18.0156) and 104.83 nmol/L is 42 ng/mL (factor 2.496).
{
analyte: "Fasting Glucose",
value: 4.94 * 18.0156,
unit: "mg/dL",
low: 70,
high: 99,
printed: { value: 4.94, unit: "mmol/L" },
},
{
analyte: "Vitamin D",
value: 104.83 / 2.496,
unit: "ng/mL",
low: 30,
high: 100,
printed: { value: 104.83, unit: "nmol/L" },
},
{ 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 },
{ analyte: "CRP", value: 0.8, unit: "mg/L", low: 0, high: 3.0 },
{ analyte: "Creatinine", value: 0.9, unit: "mg/dL", low: 0.7, high: 1.3 },
];
const recentQual: QualLab[] = [
{ analyte: "Urine Glucose", valueText: "negativ", unit: "" },
{
analyte: "Hepatitis B Surface Antigen",
valueText: "negativ",
unit: "",
},
{ analyte: "Urine Protein", valueText: "nicht nachweisbar", unit: "" },
];
// An older draw of the same numeric markers, mostly a touch higher, so the
// per-analyte history renders a trend rather than a single point.
const olderPanelAt = daysAgo(210);
const olderQuant: QuantLab[] = [
{
analyte: "Total Cholesterol",
value: 196,
unit: "mg/dL",
low: 0,
high: 200,
},
{ analyte: "LDL", value: 119, unit: "mg/dL", low: 0, high: 130 },
{ analyte: "HDL", value: 51, unit: "mg/dL", low: 40, high: 100 },
{
analyte: "Triglycerides",
value: 128,
unit: "mg/dL",
low: 0,
high: 150,
},
{ analyte: "HbA1c", value: 5.5, unit: "%", low: 4.0, high: 5.6 },
{ analyte: "Vitamin D", value: 31, unit: "ng/mL", low: 30, high: 100 },
{ analyte: "TSH", value: 2.2, unit: "mIU/L", low: 0.4, high: 4.0 },
{ analyte: "Ferritin", value: 88, unit: "ng/mL", low: 30, high: 400 },
];
const insertQuant = async (
panel: string | null,
lab: QuantLab,
takenAt: Date,
) => {
await client.query(
`INSERT INTO lab_results (id, user_id, panel, analyte, value, value_text, unit, reference_low, reference_high, source_value, source_unit, taken_at, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NULL, $6, $7, $8, $9, $10, $11, 'MANUAL', NOW(), NOW())`,
[
cuid(),
userId,
panel,
lab.analyte,
lab.value,
lab.unit,
lab.low,
lab.high,
lab.printed?.value ?? null,
lab.printed?.unit ?? null,
takenAt,
],
);
};
const insertQual = async (
panel: string | null,
lab: QualLab,
takenAt: Date,
) => {
await client.query(
`INSERT INTO lab_results (id, user_id, panel, analyte, value, value_text, unit, reference_low, reference_high, taken_at, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULL, $5, $6, NULL, NULL, $7, 'MANUAL', NOW(), NOW())`,
[cuid(), userId, panel, lab.analyte, lab.valueText, lab.unit, takenAt],
);
};
// No panel on the readings: a draw label ("Annual blood panel") would
// become one category holding every marker, and the labs list would stay
// flat. Without one each marker lands in its catalog category by name.
for (const lab of recentQuant) {
await insertQuant(null, lab, recentPanelAt);
}
for (const lab of recentQual) {
await insertQual(null, lab, recentPanelAt);
}
for (const lab of olderQuant) {
await insertQuant(null, lab, olderPanelAt);
}
const labResults = [...recentQuant, ...recentQual, ...olderQuant];
// ── Illness / condition journal ───────────
// Two episodes so the journal + per-condition timeline both render with
// signal: (1) a short, fully-resolved cold ~7 weeks ago with a day-by-day
// symptom curve, and (2) a recurring condition (seasonal allergic
// rhinitis) with a spring FLARE that hangs off it. Each episode gets
// IllnessDayLog rows (functional impact + optional fever + an encrypted
// note) and per-day symptom links from the seeded catalogue, so the
// retrospective curve + symptom chips have content. Day-log + episode
// notes are AES-256-GCM Bytes (the `*Encrypted` convention), written with
// the same codec the app uses — never plaintext.
console.log("Creating illness / condition journal...");
// The pollen flare starts about 24 weeks back, in the spring of the
// year the seed runs in, and every day of it is placed from this anchor.
const FLARE_ONSET_DAYS_AGO = 170;
// Resolve a catalogue symptom id by its stable machine key.
const illnessSymptomId = async (key: string): Promise<string> => {
const { rows } = await client.query(
`SELECT id FROM illness_symptoms WHERE key = $1`,
[key],
);
return rows[0].id as string;
};
const encBytes = (text: string) => Buffer.from(encryptToBytes(text));
// Insert one IllnessDayLog with its symptom links.
const insertIllnessDay = async (params: {
episodeId: string;
date: Date;
functionalImpact: number;
feverC: number | null;
note: string;
symptoms: Array<{ key: string; severity: number }>;
}) => {
const dayLogId = cuid();
await client.query(
`INSERT INTO illness_day_logs (id, user_id, episode_id, date, functional_impact, fever_c, note_encrypted, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())`,
[
dayLogId,
userId,
params.episodeId,
formatDate(params.date),
params.functionalImpact,
params.feverC,
encBytes(params.note),
],
);
for (const s of params.symptoms) {
await client.query(
`INSERT INTO illness_symptom_links (day_log_id, symptom_id, severity, created_at)
VALUES ($1, $2, $3, NOW())`,
[dayLogId, await illnessSymptomId(s.key), s.severity],
);
}
};
// (1) Resolved acute cold, day 49 → day 43.
const coldId = cuid();
await client.query(
`INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, note_encrypted, created_at, updated_at)
VALUES ($1, $2, 'Common cold', 'INFECTION', 'ACUTE', $3, $4, $5, NOW(), NOW())`,
[
coldId,
userId,
daysAgo(49),
daysAgo(43),
encBytes("Picked it up after a long-haul flight. Rested it off."),
],
);
// A six-day curve: worst on days 2–3, easing toward recovery.
const coldDays: Array<{
offset: number;
impact: number;
fever: number | null;
note: string;
symptoms: Array<{ key: string; severity: number }>;
}> = [
{
offset: 49,
impact: 1,
fever: null,
note: "Scratchy throat starting in the evening.",
symptoms: [
{ key: "sore_throat", severity: 2 },
{ key: "fatigue", severity: 1 },
],
},
{
offset: 48,
impact: 2,
fever: 37.8,
note: "Heavy head, runny nose all day.",
symptoms: [
{ key: "runny_nose", severity: 3 },
{ key: "headache", severity: 2 },
{ key: "fatigue", severity: 2 },
],
},
{
offset: 47,
impact: 2,
fever: 38.1,
note: "Worst day — stayed in bed, low fever.",
symptoms: [
{ key: "stuffy_nose", severity: 3 },
{ key: "body_aches", severity: 2 },
{ key: "cough", severity: 2 },
{ key: "fatigue", severity: 3 },
],
},
{
offset: 46,
impact: 1,
fever: 37.4,
note: "Fever broke overnight, congestion lingering.",
symptoms: [
{ key: "stuffy_nose", severity: 2 },
{ key: "cough", severity: 2 },
],
},
{
offset: 44,
impact: 1,
fever: null,
note: "Mostly a dry cough left now.",
symptoms: [{ key: "cough", severity: 1 }],
},
{
offset: 43,
impact: 0,
fever: null,
note: "Back to normal — calling it resolved.",
symptoms: [],
},
];
for (const d of coldDays) {
await insertIllnessDay({
episodeId: coldId,
date: daysAgo(d.offset),
functionalImpact: d.impact,
feverC: d.fever,
note: d.note,
symptoms: d.symptoms,
});
}
// (2) A recurring condition with a flare hanging off it, both closed.
// Any episode left open puts the account in Rest Mode (an ongoing chronic
// condition deliberately included), and an open one dated 400 days back
// greeted every demo visitor with "Rest mode, day 401" and a score marked
// as not penalised. The season and its flare therefore sit in the spring
// and are resolved; the illness pages still show the whole history.
const allergyId = cuid();
await client.query(
`INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, note_encrypted, created_at, updated_at)
VALUES ($1, $2, 'Seasonal allergic rhinitis', 'ALLERGY', 'RECURRING', $3, $4, $5, NOW(), NOW())`,
[
allergyId,
userId,
daysAgo(400),
daysAgo(FLARE_ONSET_DAYS_AGO - 14),
encBytes("Tree pollen each spring. Antihistamine helps."),
],
);
// The flare: a bout at the height of the pollen season, referencing the
// parent, resolved a week after it started.
const flareId = cuid();
await client.query(
`INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, parent_condition_id, note_encrypted, created_at, updated_at)
VALUES ($1, $2, 'Pollen flare', 'ALLERGY', 'FLARE', $3, $4, $5, $6, NOW(), NOW())`,
[
flareId,
userId,
daysAgo(FLARE_ONSET_DAYS_AGO),
daysAgo(FLARE_ONSET_DAYS_AGO - 7),
allergyId,
encBytes("High pollen count this week — symptoms back."),
],
);
const flareDays: Array<{
offset: number;
impact: number;
note: string;
symptoms: Array<{ key: string; severity: number }>;
}> = [
{
offset: FLARE_ONSET_DAYS_AGO,
impact: 1,
note: "Itchy eyes and sneezing started today.",
symptoms: [
{ key: "sneezing", severity: 2 },
{ key: "runny_nose", severity: 2 },
],
},
{
offset: FLARE_ONSET_DAYS_AGO - 1,
impact: 1,
note: "Pollen high again — runny nose all morning.",
symptoms: [
{ key: "runny_nose", severity: 3 },
{ key: "sneezing", severity: 2 },
],
},
{
offset: FLARE_ONSET_DAYS_AGO - 3,
impact: 1,
note: "Antihistamine taking the edge off.",
symptoms: [
{ key: "stuffy_nose", severity: 2 },
{ key: "fatigue", severity: 1 },
],
},
{
offset: FLARE_ONSET_DAYS_AGO - 6,
impact: 1,
note: "Still sniffly but manageable.",
symptoms: [{ key: "runny_nose", severity: 1 }],
},
];
for (const d of flareDays) {
await insertIllnessDay({
episodeId: flareId,
date: daysAgo(d.offset),
functionalImpact: d.impact,
feverC: null,
note: d.note,
symptoms: d.symptoms,
});
}
// ── Cycle tracking ────────────────────────
// The cycle module resolves through isCycleEnabled(gender, CycleProfile):
// a NULL flag derives from gender, so this account explicitly opts in
// (cycle_tracking_enabled = true) — the same posture the e2e seed takes —
// so the cycle vertical renders. Seed ~5 observed cycles of realistic
// length with a full symptothermal day-log: biphasic basal body
// temperature (lower follicular, a sustained ~0.3 °C luteal shift after
// ovulation), period flow on the bleeding days, fertile-window mucus + a
// positive OPK around ovulation, and per-day symptoms. Each BBT also lands
// as a BODY_TEMPERATURE Measurement (the schema's documented dual-write so
// the temperature charts elsewhere have the series). A cached
// CyclePrediction forward-mints the next period + fertile window so the
// prediction surface has signal. All date strings are anchored to the
// user's Europe/Berlin timezone.
console.log("Creating cycle tracking...");
const CYCLE_TZ = "Europe/Berlin";
await client.query(
`INSERT INTO cycle_profiles (id, user_id, goal, cycle_tracking_enabled, typical_cycle_length, typical_period_length, luteal_phase_length, secondary_symptom, prediction_enabled, created_at, updated_at)
VALUES ($1, $2, 'TRYING_TO_CONCEIVE', true, 29, 5, 14, 'MUCUS', true, NOW(), NOW())
ON CONFLICT (user_id) DO UPDATE SET cycle_tracking_enabled = true, updated_at = NOW()`,
[cuid(), userId],
);
// Resolve a cycle-symptom catalogue id by its stable machine key.
const cycleSymptomId = async (key: string): Promise<string> => {
const { rows } = await client.query(
`SELECT id FROM cycle_symptoms WHERE key = $1`,
[key],
);
return rows[0].id as string;
};
// Build cycles backwards from the most recent period start. Lengths jitter
// a little around the 29-day typical so the variability-aware predictor has
// something to chew on. The most recent cycle is left open-ended (no
// endDate) — its next period is what the prediction forecasts.
const cycleLengths = [29, 28, 30, 29, 27]; // most-recent first
const periodLen = 5;
// Most recent period started 8 days ago (so today sits early-follicular,
// a few days after this cycle's bleed) — keeps the wheel mid-cycle.
let cursor = 8;
const cycleStartsAgo: number[] = [];
for (const len of cycleLengths) {
cycleStartsAgo.push(cursor);
cursor += len;
}
// One Berlin YYYY-MM-DD per day offset.
const berlinDateString = (offset: number): string =>
new Intl.DateTimeFormat("en-CA", {
timeZone: CYCLE_TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(daysAgo(offset));
for (let c = 0; c < cycleStartsAgo.length; c++) {
const startAgo = cycleStartsAgo[c];
const length = cycleLengths[c];
const isMostRecent = c === 0;
// endDate / length only known once the next cycle anchors it.
const endAgo = isMostRecent ? null : cycleStartsAgo[c - 1] + 1;
const ovulationAgo = startAgo - (length - 14); // luteal = 14d
const cycleId = cuid();
await client.query(
`INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, true, false, $8, NOW(), NOW())`,
[
cycleId,
userId,
berlinDateString(startAgo),
endAgo === null ? null : berlinDateString(endAgo),
berlinDateString(startAgo - (periodLen - 1)),
isMostRecent ? null : length,
berlinDateString(ovulationAgo),
CYCLE_TZ,
],
);
// Day-by-day log across the cycle span (start day back to the day before
// the next period). For the most recent open cycle, only log up to today.
const lastDayAgo = isMostRecent ? 0 : cycleStartsAgo[c - 1] + 1;
for (let dayAgo = startAgo; dayAgo >= lastDayAgo; dayAgo--) {
const cycleDay = startAgo - dayAgo + 1; // 1-based day in cycle
const daysFromOvulation = ovulationAgo - dayAgo; // <0 pre, 0 = ovu
// Biphasic BBT: ~36.40 °C follicular, rising to ~36.70 °C in the
// luteal phase from the day after ovulation, with small daily noise.
const luteal = cycleDay > length - 14;
const bbt =
Math.round(
((luteal ? 36.7 : 36.4) + (Math.random() - 0.5) * 0.12) * 100,
) / 100;
// Flow on the first `periodLen` days, tapering.
let flow: string | null = null;
if (cycleDay <= periodLen) {
flow =
cycleDay <= 2 ? "MEDIUM" : cycleDay <= 4 ? "LIGHT" : "SPOTTING";
}
// Fertile-window mucus + OPK around ovulation.
let mucus: string | null = null;
let opk: string | null = null;
if (daysFromOvulation >= -4 && daysFromOvulation <= 1) {
mucus =
daysFromOvulation >= -1 && daysFromOvulation <= 0
? "EGG_WHITE"
: daysFromOvulation === -2
? "WATERY"
: "CREAMY";
opk = daysFromOvulation === -1 ? "POSITIVE_LH_SURGE" : "NEGATIVE";
} else if (cycleDay > periodLen) {
mucus = luteal ? "STICKY" : "DRY";
}
const dayLogId = cuid();
await client.query(
`INSERT INTO cycle_day_logs (id, user_id, date, cycle_id, flow, basal_body_temp_c, ovulation_test, cervical_mucus, source, tz, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'MANUAL', $9, NOW(), NOW())
ON CONFLICT (user_id, date) DO NOTHING`,
[
dayLogId,
userId,
berlinDateString(dayAgo),
cycleId,
flow,
bbt,
opk,
mucus,
CYCLE_TZ,
],
);
// BBT also as a BODY_TEMPERATURE Measurement (schema dual-write).
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'BODY_TEMPERATURE', $3, 'celsius', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, bbt, daysAgoAt(dayAgo, 6, 30)],
);
// Per-day symptoms: cramps + back pain during the period, mood swings
// + cravings + breast tenderness in the late-luteal PMS window.
const symptoms: Array<{ key: string; severity: number }> = [];
if (cycleDay <= 3) {
symptoms.push({ key: "cramps", severity: cycleDay <= 2 ? 3 : 2 });
symptoms.push({ key: "back_pain", severity: 2 });
symptoms.push({ key: "fatigue", severity: 2 });
}
if (cycleDay > length - 4 && cycleDay <= length) {
symptoms.push({ key: "mood_swings", severity: 2 });
symptoms.push({ key: "food_cravings", severity: 2 });
symptoms.push({ key: "breast_tenderness", severity: 2 });
}
for (const s of symptoms) {
await client.query(
`INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (day_log_id, symptom_id) DO NOTHING`,
[dayLogId, await cycleSymptomId(s.key), s.severity],
);
}
}
}
// Cached forward prediction (the stale-while-revalidate cache the read
// surface serves). Next period ~21 days out (most recent cycle started 8
// days ago, typical 29), with a ±2 day band, a fertile window ~12 days
// before that, and the matching ovulation estimate.
const nextStartAgo = cycleStartsAgo[0] - 29;
await client.query(
`INSERT INTO cycle_predictions (id, user_id, method, next_period_start, next_period_start_low, next_period_start_high, fertile_window_start, fertile_window_end, predicted_ovulation, confidence, cycles_observed, generated_at, created_at, updated_at)
VALUES ($1, $2, 'SYMPTOTHERMAL', $3, $4, $5, $6, $7, $8, 0.82, $9, NOW(), NOW(), NOW())
ON CONFLICT (user_id) DO UPDATE SET
method = EXCLUDED.method,
next_period_start = EXCLUDED.next_period_start,
next_period_start_low = EXCLUDED.next_period_start_low,
next_period_start_high = EXCLUDED.next_period_start_high,
fertile_window_start = EXCLUDED.fertile_window_start,
fertile_window_end = EXCLUDED.fertile_window_end,
predicted_ovulation = EXCLUDED.predicted_ovulation,
confidence = EXCLUDED.confidence,
cycles_observed = EXCLUDED.cycles_observed,
generated_at = NOW(),
updated_at = NOW()`,
[
cuid(),
userId,
berlinDateString(nextStartAgo),
berlinDateString(nextStartAgo + 2),
berlinDateString(nextStartAgo - 2),
berlinDateString(nextStartAgo + 17),
berlinDateString(nextStartAgo + 12),
berlinDateString(nextStartAgo + 14),
cycleLengths.length,
],
);
// ── Visits, a procedure and its documents ──
// Two practitioners, a past knee arthroscopy with its two letters linked,
// the follow-up visit that came after it, and a check-up booked for
// tomorrow morning. The booked visit is what the start page's Today
// overview and worth-a-look rail read; the procedure with linked files is
// what gives the documents page its procedure filter. Documents are real
// (tiny) PDFs, encrypted with the binary codec the upload path writes.
console.log("Creating visits, a procedure and its documents...");
const familyPracticeId = cuid();
const orthopaedicsId = cuid();
for (const [id, name, specialty] of [
[familyPracticeId, "Family practice", "General practice"],
[orthopaedicsId, "Orthopaedic clinic", "Orthopaedics"],
]) {
await client.query(
`INSERT INTO practitioners (id, user_id, name, specialty, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())`,
[id, userId, name, specialty],
);
}
const insertEncounter = async (params: {
occurredAt: Date;
status: "DONE" | "PLANNED";
kind: "ROUTINE" | "PROCEDURE" | "PREVENTIVE" | "SPECIALIST" | "ACUTE";
practitionerId: string;
reason: string;
outcome?: string;
bodySite?: string;
laterality?: "LEFT" | "RIGHT";
}): Promise<string> => {
const id = cuid();
await client.query(
`INSERT INTO encounters (id, user_id, occurred_at, status, kind, practitioner_id, reason_encrypted, outcome_encrypted, body_site_encrypted, laterality, created_at, updated_at)
VALUES ($1, $2, $3, $4::encounter_status, $5::encounter_kind, $6, $7, $8, $9, $10::laterality, NOW(), NOW())`,
[
id,
userId,
params.occurredAt,
params.status,
params.kind,
params.practitionerId,
encBytes(params.reason),
params.outcome ? encBytes(params.outcome) : null,
params.bodySite ? encBytes(params.bodySite) : null,
params.laterality ?? null,
],
);
return id;
};
const procedureAt = daysAgoAt(150, 8, 30);
const procedureId = await insertEncounter({
occurredAt: procedureAt,
status: "DONE",
kind: "PROCEDURE",
practitionerId: orthopaedicsId,
reason: "Arthroscopy for a torn meniscus after a running injury.",
outcome:
"Partial meniscectomy, day surgery. Physiotherapy for six weeks, " +
"back to easy running after that.",
bodySite: "knee",
laterality: "LEFT",
});
await insertEncounter({
occurredAt: daysAgoAt(136, 10, 15),
status: "DONE",
kind: "ROUTINE",
practitionerId: orthopaedicsId,
reason: "Follow-up two weeks after the arthroscopy.",
outcome: "Healing well, full range of motion. Physiotherapy continues.",
});
// The visit that turned the busy stretch: ten days of high home readings,
// ramipril raised that morning.
await insertEncounter({
occurredAt: daysAgoAt(BUSY_STRETCH.doseUp, 8, 15),
status: "DONE",
kind: "ROUTINE",
practitionerId: familyPracticeId,
reason: "Home blood pressure high for about ten days.",
outcome: `Readings around 144/91 during a stressful stretch at work. Ramipril raised to ${RAMIPRIL_STRETCH_DOSE} mg, home readings again in two weeks.`,
});
await insertEncounter({
occurredAt: daysAgoAt(-1, 9, 30),
status: "PLANNED",
kind: "ROUTINE",
practitionerId: familyPracticeId,
reason: "Annual check-up with a blood panel.",
});
const procedureDocs: Array<{
kind: "DOCTOR_REPORT" | "DISCHARGE_LETTER";
title: string;
filename: string;
date: Date;
lines: string[];
}> = [
{
kind: "DOCTOR_REPORT",
title: "Operative report, knee arthroscopy",
filename: "operative-report-knee-arthroscopy.pdf",
date: procedureAt,
lines: [
"Operative report",
"Procedure: arthroscopic partial meniscectomy, left knee",
"Anaesthesia: general, day surgery",
"Findings: radial tear of the medial meniscus, cartilage intact",
"Course: uneventful",
],
},
{
kind: "DISCHARGE_LETTER",
title: "Discharge letter, day surgery",
filename: "discharge-letter-day-surgery.pdf",
date: procedureAt,
lines: [
"Discharge letter",
"Discharged the same afternoon in good condition",
"Weight-bearing as tolerated, crutches for three days",
"Physiotherapy: twice a week for six weeks",
"Follow-up in the clinic after two weeks",
],
},
];
for (const doc of procedureDocs) {
const pdf = simplePdf(doc.lines);
const docId = cuid();
await client.query(
`INSERT INTO inbound_documents (id, user_id, kind, title, filename, mime_type, byte_size, content_encrypted, content_sha256, content_codec, status, document_date, created_at, updated_at)
VALUES ($1, $2, $3::inbound_document_kind, $4, $5, 'application/pdf', $6, $7, $8, 'binary2', 'STORED', $9, $9, $9)`,
[
docId,
userId,
doc.kind,
doc.title,
doc.filename,
pdf.byteLength,
Buffer.from(encryptBytes(pdf)),
createHash("sha256").update(pdf).digest("hex"),
doc.date,
],
);
await client.query(
`INSERT INTO encounter_document_links (id, encounter_id, document_id, user_id, created_at)
VALUES ($1, $2, $3, $4, NOW())`,
[cuid(), procedureId, docId, userId],
);
}
// ── Achievements ─────────────────────────
// ── Multi-year history ────────────────────
// The 90-day window above is what the dashboard reads; the timeline and
// the day view read years. This section writes the record before that
// window, about four and a half years back, with the density a real one
// has: blood pressure in bursts with a quarter of silence after the
// daughter's birth, weight weekly, a phone's step count all along, and a
// watch's resting heart rate, HRV and sleep from the third year on. Around
// it: the blood-pressure work-up that started ramipril, its dose change
// and sick-day pause, illnesses with start and end, flu shots, check-ups
// with their blood panels, life events at day, month and year precision,
// the home location with a summer holiday, and the stored weather, air
// quality and pollen for every day of the record. Every value is invented.
console.log("Creating multi-year history...");
const today = berlinDayKey();
const dayKeyAgo = (n: number) => berlinDayKey(daysAgoAt(n, 12));
// Measurements before the 90-day window.
const historyRows: unknown[][] = [];
const addMeasurement = (
type: string,
value: number,
unit: string,
source: string,
at: Date,
) => {
historyRows.push([cuid(), userId, type, value, unit, source, at, at, at]);
};
// Blood pressure: a quarter without a reading after the birth (the line
// breaks into a dashed bridge there) and one early quarter with only two
// readings (drawn hollow).
const silentBpQuarter = quarterOf(dayKeyAgo(DAUGHTER_BORN_DAYS_AGO - 45));
const thinBpQuarter = quarterOf(dayKeyAgo(1380));
const thinWeightQuarter = silentBpQuarter;
const quarterCounts = new Map<string, number>();
const sysKnots: Array<[number, number]> = [
[HISTORY_DAYS, 134],
[1160, 143],
[RAMIPRIL_START_DAYS_AGO, 142],
[1000, 136],
[RAMIPRIL_DOSE_UP_DAYS_AGO, 135],
[600, 130],
[91, 128],
];
const diaKnots: Array<[number, number]> = [
[HISTORY_DAYS, 86],
[1160, 92],
[RAMIPRIL_START_DAYS_AGO, 91],
[1000, 87],
[RAMIPRIL_DOSE_UP_DAYS_AGO, 86],
[600, 83],
[91, 82],
];
const weightKnots: Array<[number, number]> = [
[HISTORY_DAYS, 83.4],
[1180, 85.2],
[DAUGHTER_BORN_DAYS_AGO, 88.6],
[420, 87.9],
[91, 86.5],
];
const restingKnots: Array<[number, number]> = [
[WATCH_FROM_DAYS_AGO, 63],
[DAUGHTER_BORN_DAYS_AGO, 65],
[500, 63],
[91, 64],
];
const hrvKnots: Array<[number, number]> = [
[WATCH_FROM_DAYS_AGO, 46],
[DAUGHTER_BORN_DAYS_AGO, 39],
[480, 45],
[91, 48],
];
const sleepKnots: Array<[number, number]> = [
[WATCH_FROM_DAYS_AGO, 445],
[DAUGHTER_BORN_DAYS_AGO + 5, 440],
[DAUGHTER_BORN_DAYS_AGO - 10, 365],
[480, 410],
[91, 420],
];
const stepKnots: Array<[number, number]> = [
[HISTORY_DAYS, 6300],
[DAUGHTER_BORN_DAYS_AGO, 5600],
[560, 7400],
[91, 6500],
];
// The watch is off the wrist for a fortnight one summer (a broken strap).
const watchOff = { from: 300, to: 287 };
for (let n = HISTORY_DAYS; n > days; n--) {
const at = daysAgo(n);
const key = dayKeyAgo(n);
const quarter = quarterOf(key);
const paused = n <= RAMIPRIL_PAUSE.from && n >= RAMIPRIL_PAUSE.to;
// Blood pressure, in bursts: daily during the work-up, a few times a
// week once treatment settled, now and then before it all started.
const bpChance =
n > 1165
? 0.12
: n > 1090
? 0.9
: n > RAMIPRIL_DOSE_UP_DAYS_AGO - 30 &&
n < RAMIPRIL_DOSE_UP_DAYS_AGO + 20
? 0.7
: paused || (n < RAMIPRIL_PAUSE.to && n > RAMIPRIL_PAUSE.to - 14)
? 0.9
: 0.3;
const bpTaken = quarterCounts.get(`bp:${quarter}`) ?? 0;
const bpAllowed =
quarter === silentBpQuarter
? false
: quarter === thinBpQuarter
? bpTaken < 2
: true;
if (bpAllowed && Math.random() < bpChance) {
quarterCounts.set(`bp:${quarter}`, bpTaken + 1);
const bump = paused ? 7 : 0;
const sys = Math.round(knotValue(sysKnots, n) + bump + jitter(6));
const dia = Math.round(knotValue(diaKnots, n) + bump / 2 + jitter(4));
addMeasurement("BLOOD_PRESSURE_SYS", sys, "mmHg", "MANUAL", at);
addMeasurement("BLOOD_PRESSURE_DIA", dia, "mmHg", "MANUAL", at);
addMeasurement(
"PULSE",
Math.round(72 + jitter(6)),
"bpm",
"MANUAL",
at,
);
}
// Weight, once a week on the bathroom scale (thin after the birth).
if (n % 7 === 3) {
const wTaken = quarterCounts.get(`w:${quarter}`) ?? 0;
if (quarter !== thinWeightQuarter || wTaken < 2) {
quarterCounts.set(`w:${quarter}`, wTaken + 1);
addMeasurement(
"WEIGHT",
Math.round((knotValue(weightKnots, n) + jitter(0.6)) * 10) / 10,
"kg",
"WITHINGS",
at,
);
}
}
// Steps from the phone, every day of the record.
addMeasurement(
"ACTIVITY_STEPS",
Math.max(1200, Math.round(knotValue(stepKnots, n) + jitter(2200))),
"steps",
"APPLE_HEALTH",
at,
);
// The watch: resting heart rate, HRV and sleep, daily from year three.
const wearing =
n <= WATCH_FROM_DAYS_AGO &&
!(n <= watchOff.from && n >= watchOff.to) &&
Math.random() > 0.04;
if (wearing) {
addMeasurement(
"RESTING_HEART_RATE",
Math.round(knotValue(restingKnots, n) + jitter(3)),
"bpm",
"APPLE_HEALTH",
at,
);
addMeasurement(
"HEART_RATE_VARIABILITY",
Math.round(knotValue(hrvKnots, n) + jitter(7)),
"ms",
"APPLE_HEALTH",
at,
);
addMeasurement(
"SLEEP_DURATION",
Math.round(knotValue(sleepKnots, n) + jitter(40)),
"minutes",
"APPLE_HEALTH",
at,
);
}
}
await bulkInsert(
client,
"measurements",
[
"id",
"user_id",
"type",
"value",
"unit",
"source",
"measured_at",
"created_at",
"updated_at",
],
historyRows,
);
// Ramipril and Vitamin D3 intakes before the 90-day window, at the same
// slot anchoring as the window itself, and none while ramipril is paused.
const intakeRows: unknown[][] = [];
const addIntake = (medicationId: string, n: number, compliance: number) => {
const slot = berlinHmAsUtc(8, 0, daysAgo(n));
if (Math.random() < compliance) {
const taken = new Date(
slot.getTime() + (5 + Math.floor(Math.random() * 50)) * 60_000,
);
intakeRows.push([
cuid(),
userId,
medicationId,
slot,
taken,
false,
"WEB",
taken,
taken,
]);
} else {
intakeRows.push([
cuid(),
userId,
medicationId,
slot,
null,
true,
"WEB",
slot,
slot,
]);
}
};
for (let n = RAMIPRIL_START_DAYS_AGO; n > days; n--) {
if (n <= RAMIPRIL_PAUSE.from && n >= RAMIPRIL_PAUSE.to) continue;
addIntake(med1Id, n, 0.97);
if (n <= VITAMIN_D_START_DAYS_AGO) addIntake(med2Id, n, 0.94);
}
await bulkInsert(
client,
"medication_intake_events",
[
"id",
"user_id",
"medication_id",
"scheduled_for",
"taken_at",
"skipped",
"source",
"created_at",
"updated_at",
],
intakeRows,
);
// The ramipril dose history and its sick-day pause. The last step falls
// inside the 90-day window, on the day the busy stretch turns.
for (const [n, value] of [
[RAMIPRIL_START_DAYS_AGO, 2.5],
[RAMIPRIL_DOSE_UP_DAYS_AGO, 5],
[BUSY_STRETCH.doseUp, RAMIPRIL_STRETCH_DOSE],
] as const) {
await client.query(
`INSERT INTO medication_dose_changes (id, medication_id, effective_from, dose_value, dose_unit, created_at)
VALUES ($1, $2, $3, $4, 'mg', $3)`,
[cuid(), med1Id, daysAgoAt(n, 8), value],
);
}
await client.query(
`INSERT INTO medication_pause_eras (id, medication_id, user_id, paused_at, resumed_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
cuid(),
med1Id,
userId,
daysAgoAt(RAMIPRIL_PAUSE.from, 9),
daysAgoAt(RAMIPRIL_PAUSE.to - 1, 8),
daysAgoAt(RAMIPRIL_PAUSE.from, 9),
],
);
// A finished antibiotic course for the bronchitis.
await client.query(
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, starts_on, ends_on, created_at, updated_at)
VALUES ($1, $2, 'Amoxicillin', '1000mg', false, false, $3, $4, $5, $5)`,
[
cuid(),
userId,
formatDate(daysAgo(797)),
formatDate(daysAgo(791)),
daysAgo(797),
],
);
// Illnesses before the window, each with a start and an end. The back
// pain is the chronic one; it settled after a course of physiotherapy,
// so nothing here is left open (an open episode puts the account in
// Rest Mode).
for (const ep of [
{
label: "Chronic lower back pain",
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
from: 1560,
to: 1240,
note: "Desk job and too little movement. Physiotherapy twice a week.",
},
{
label: "Influenza",
type: "INFECTION",
lifecycle: "ACUTE",
from: 1370,
to: 1361,
note: "High fever for three days, a week in bed.",
},
{
label: "Acute bronchitis",
type: "INFECTION",
lifecycle: "ACUTE",
from: 800,
to: 788,
note: "Cough that would not settle. Antibiotics from the practice.",
},
{
label: "Gastroenteritis",
type: "INFECTION",
lifecycle: "ACUTE",
from: 427,
to: 422,
note: "Stomach bug from daycare. Ramipril paused until eating again.",
},
]) {
await client.query(
`INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, note_encrypted, created_at, updated_at)
VALUES ($1, $2, $3, $4::illness_type, $5::illness_lifecycle, $6, $7, $8, $6, $7)`,
[
cuid(),
userId,
ep.label,
ep.type,
ep.lifecycle,
daysAgo(ep.from),
daysAgo(ep.to),
encBytes(ep.note),
],
);
}
// A cardiology practice for the work-up, then the visits over the years.
const cardiologyId = cuid();
await client.query(
`INSERT INTO practitioners (id, user_id, name, specialty, created_at, updated_at)
VALUES ($1, $2, 'Cardiology practice', 'Cardiology', $3, $3)`,
[cardiologyId, userId, daysAgo(1140)],
);
const historyVisits: Array<{
n: number;
kind: "ROUTINE" | "PREVENTIVE" | "SPECIALIST" | "ACUTE";
practitionerId: string;
reason: string;
outcome: string;
}> = [
{
n: 1560,
kind: "PREVENTIVE",
practitionerId: familyPracticeId,
reason: "Health check-up with a blood panel.",
outcome: "All fine. Back pain: referral to physiotherapy.",
},
{
n: 1190,
kind: "PREVENTIVE",
practitionerId: familyPracticeId,
reason: "Health check-up with a blood panel.",
outcome:
"Blood pressure high at the practice. Home readings for two weeks.",
},
{
n: 1140,
kind: "SPECIALIST",
practitionerId: cardiologyId,
reason: "Blood pressure work-up with a 24-hour measurement.",
outcome: "Mild hypertension, heart otherwise healthy. Ramipril 2.5 mg.",
},
{
n: 830,
kind: "PREVENTIVE",
practitionerId: familyPracticeId,
reason: "Health check-up with a blood panel.",
outcome: "Lipids slightly up. More exercise, recheck next year.",
},
{
n: 797,
kind: "ACUTE",
practitionerId: familyPracticeId,
reason: "Cough and fever for five days.",
outcome: "Acute bronchitis. Amoxicillin for seven days.",
},
{
n: RAMIPRIL_DOSE_UP_DAYS_AGO,
kind: "ROUTINE",
practitionerId: familyPracticeId,
reason: "Blood pressure follow-up.",
outcome: "Still above target, ramipril raised to 5 mg.",
},
{
n: 470,
kind: "PREVENTIVE",
practitionerId: familyPracticeId,
reason: "Health check-up with a blood panel.",
outcome: "Blood pressure on target. Lipids better.",
},
{
n: 426,
kind: "ACUTE",
practitionerId: familyPracticeId,
reason: "Vomiting and diarrhoea since yesterday.",
outcome:
"Gastroenteritis. Pause ramipril until eating and drinking normally.",
},
];
for (const v of historyVisits) {
await insertEncounter({
occurredAt: daysAgoAt(v.n, 9, 15),
status: "DONE",
kind: v.kind,
practitionerId: v.practitionerId,
reason: v.reason,
outcome: v.outcome,
});
}
// The blood panels of those check-ups: HbA1c, lipids and haemoglobin.
const historyPanels: Array<{ n: number; values: Record<string, number> }> =
[
{
n: 1560,
values: { hba1c: 5.3, tc: 205, ldl: 128, hdl: 49, hb: 15.1 },
},
{
n: 1190,
values: { hba1c: 5.4, tc: 214, ldl: 137, hdl: 47, hb: 14.8 },
},
{ n: 1140, values: { tc: 216, ldl: 140, hdl: 47 } },
{
n: 830,
values: { hba1c: 5.6, tc: 209, ldl: 132, hdl: 48, hb: 15.0 },
},
{
n: 470,
values: { hba1c: 5.5, tc: 199, ldl: 121, hdl: 51, hb: 14.6 },
},
];
const panelLabs: Record<string, Omit<QuantLab, "value">> = {
hba1c: { analyte: "HbA1c", unit: "%", low: 4.0, high: 5.6 },
tc: { analyte: "Total Cholesterol", unit: "mg/dL", low: 0, high: 200 },
ldl: { analyte: "LDL", unit: "mg/dL", low: 0, high: 130 },
hdl: { analyte: "HDL", unit: "mg/dL", low: 40, high: 100 },
hb: { analyte: "Hemoglobin", unit: "g/dL", low: 13.5, high: 17.5 },
};
for (const panel of historyPanels) {
for (const [key, value] of Object.entries(panel.values)) {
await insertQuant(
null,
{ ...panelLabs[key], value },
daysAgoAt(panel.n, 9, 0),
);
}
}
// Vaccinations: a flu shot every autumn and a tetanus-diphtheria-
// pertussis booster. The newest flu shot is recent, so nothing is due.
for (const v of [
{ n: 1440, slug: "influenza", name: "Influenza" },
{ n: 1075, slug: "influenza", name: "Influenza" },
{ n: 985, slug: "tdap", name: "Tdap" },
{ n: 710, slug: "influenza", name: "Influenza" },
{ n: 345, slug: "influenza", name: "Influenza" },
{ n: 12, slug: "influenza", name: "Influenza" },
]) {
const at = daysAgoAt(v.n, 10, 30);
await client.query(
`INSERT INTO vaccination_records (id, user_id, occurred_at, antigen_slug, vaccine_name, site, practitioner_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, 'LEFT_ARM'::vaccination_site, $6, $3, $3)`,
[cuid(), userId, at, v.slug, v.name, familyPracticeId],
);
}
// Life events, at the precision a person actually remembers them.
const monthStart = (n: number) => `${dayKeyAgo(n).slice(0, 7)}-01`;
const lastYear = Number(today.slice(0, 4)) - 1;
const lifeEvents: Array<{
category: "FAMILY" | "HOME" | "WORK" | "OTHER";
start: string;
end: string | null;
precision: "DAY" | "MONTH" | "YEAR";
title: string;
note?: string;
}> = [
{
category: "HOME",
start: monthStart(HISTORY_DAYS),
end: null,
precision: "MONTH",
title: "Moved to Freiburg",
},
{
category: "WORK",
start: dayKeyAgo(1185),
end: null,
precision: "DAY",
title: "Started a new job",
note: "Longer commute, more sitting.",
},
{
category: "FAMILY",
start: dayKeyAgo(DAUGHTER_BORN_DAYS_AGO),
end: null,
precision: "DAY",
title: "Our daughter was born",
},
{
category: "FAMILY",
start: monthStart(DAUGHTER_BORN_DAYS_AGO - 20),
end: monthStart(DAUGHTER_BORN_DAYS_AGO - 80),
precision: "MONTH",
title: "Parental leave",
},
{
category: "OTHER",
start: `${lastYear}-01-01`,
end: null,
precision: "YEAR",
title: "Started running regularly",
},
{
category: "HOME",
start: dayKeyAgo(380),
end: null,
precision: "DAY",
title: "Moved into a bigger flat",
},
{
category: "FAMILY",
start: dayKeyAgo(46),
end: null,
precision: "DAY",
title: "First day at daycare",
note: "Settling-in week, short days.",
},
];
for (const e of lifeEvents) {
await client.query(
`INSERT INTO life_events (id, user_id, category, start_date, end_date, precision, title_encrypted, note_encrypted, created_at, updated_at)
VALUES ($1, $2, $3::life_event_category, $4, $5, $6::life_event_precision, $7, $8, NOW(), NOW())`,
[
cuid(),
userId,
e.category,
e.start,
e.end,
e.precision,
encBytes(e.title),
e.note ? encBytes(e.note) : null,
],
);
}
// The timeline module is opt-in; the demo shows it. The home location
// is sealed the way the settings route writes it.
const homeSince = daysAgoAt(HISTORY_DAYS, 12);
await client.query(
`UPDATE users
SET module_preferences_json = $2,
home_location_encrypted = $3,
home_timezone = 'Europe/Berlin',
home_since = $4,
environment_air_quality_enabled = true
WHERE id = $1`,
[
userId,
JSON.stringify({ timeline: true }),
sealDemoLocation(DEMO_HOME),
homeSince,
],
);
await client.query(
`INSERT INTO environment_travel_locations (id, user_id, start_date, end_date, location_encrypted, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())`,
[
cuid(),
userId,
dayKeyAgo(HOLIDAY.from),
dayKeyAgo(HOLIDAY.to),
sealDemoLocation(DEMO_HOLIDAY_PLACE),
],
);
// Stored weather, air quality and pollen for every day of the record,
// written as the nightly fetch would leave them (no feed is called).
// Seasonal shapes for a city in the south-west: mild winters, warm
// summers, alder and birch in spring, grass into July, mugwort and
// ragweed from late summer well into October.
const sealedHome = sealDemoLocation(DEMO_HOME);
const sealedHoliday = sealDemoLocation(DEMO_HOLIDAY_PLACE);
const envRows: unknown[][] = [];
let cloud = 55;
const fetchedAt = new Date();
for (let n = HISTORY_DAYS; n >= 0; n--) {
const key = dayKeyAgo(n);
const away = n <= HOLIDAY.from && n >= HOLIDAY.to;
const doy = dayOfYear(key);
const season = Math.sin((2 * Math.PI * (doy - 110)) / 365);
cloud = Math.min(
100,
Math.max(0, cloud + (60 - cloud) * 0.3 + jitter(35)),
);
const rainy = cloud > 72 && Math.random() < 0.7;
const tempMean =
Math.round(
((away ? 9.5 : 11) +
(away ? 8 : 9.5) * season +
jitter(3) -
(rainy ? 1.5 : 0)) *
10,
) / 10;
const tempMin = Math.round((tempMean - 4 - Math.random() * 2) * 10) / 10;
const tempMax =
Math.round(
(tempMean + 4.5 + Math.random() * 2.5 - (rainy ? 2 : 0)) * 10,
) / 10;
const daylight = daylightHours(
away ? DEMO_HOLIDAY_PLACE.lat : DEMO_HOME.lat,
doy,
);
const sunshine = Math.max(0, daylight * (1 - cloud / 100) * 0.85);
const precip = rainy ? Math.round((1 + Math.random() * 14) * 10) / 10 : 0;
const weatherCode = rainy
? tempMean < 1
? 71
: precip > 8
? 63
: 61
: cloud > 80
? 3
: cloud > 35
? 2
: cloud > 15
? 1
: 0;
// Air: more particles in winter (heating, inversions), more ozone in
// summer, a little cleaner on rainy days and by the sea.
const winter = (1 - season) / 2;
const clean = (rainy ? 0.7 : 1) * (away ? 0.6 : 1);
const pm25Mean =
Math.round((5 + 9 * winter + jitter(2.5)) * clean * 10) / 10;
const pm25Max =
Math.round(pm25Mean * (1.5 + Math.random() * 0.6) * 10) / 10;
const pm10Mean = Math.round((pm25Mean * 1.6 + jitter(2)) * 10) / 10;
const no2Mean =
Math.round((12 + 10 * winter + jitter(4)) * clean * 10) / 10;
const o3Max8h = Math.round(
(55 + 45 * (1 - winter) + jitter(12)) * (rainy ? 0.8 : 1),
);
const eaqiMax = Math.round(
Math.max(pm25Max * 1.7, pm10Mean * 0.9, o3Max8h * 0.42, no2Mean * 0.8),
);
const usaqiMax = Math.round(Math.max(pm25Max * 3.1, o3Max8h * 0.45));
const uvIndexMax =
Math.round(
Math.max(
0.3,
(1 + 6.5 * (1 - winter)) * (1 - cloud / 160) + jitter(0.4),
) * 10,
) / 10;
// Pollen, damped on rainy days, none by the sea in late summer.
const damp = (rainy ? 0.35 : 1) * (0.7 + Math.random() * 0.6);
const pollen = (from: number, to: number, peak: number) =>
Math.round(pollenSeason(doy, from, to, peak) * damp);
envRows.push([
cuid(),
userId,
key,
away ? sealedHoliday : sealedHome,
away ? "TRAVEL" : "HOME",
tempMin,
tempMax,
tempMean,
Math.round((tempMean - (rainy ? 2 : 0.5)) * 10) / 10,
Math.round(sunshine * 3600),
Math.round(daylight * 3600),
precip,
Math.round((1016 - (rainy ? 9 : 0) + jitter(5)) * 10) / 10,
Math.round((2 + Math.random() * (rainy ? 8 : 4)) * 10) / 10,
Math.round(60 + cloud * 0.25 + jitter(8)),
Math.round(cloud),
weatherCode,
Math.round((tempMax + (rainy ? -1 : 1.5)) * 10) / 10,
pm25Mean,
pm25Max,
pm10Mean,
no2Mean,
Math.round(1.2 + Math.random() * 1.5),
Math.round(180 + 120 * winter + jitter(30)),
o3Max8h,
eaqiMax,
usaqiMax,
uvIndexMax,
Math.round(Math.random() * (doy > 90 && doy < 250 ? 18 : 4)),
Math.round((0.08 + Math.random() * 0.2) * 100) / 100,
pollen(35, 85, 70),
pollen(92, 135, away ? 20 : 190),
pollen(128, 215, 85),
away ? 0 : pollen(195, 255, 28),
0,
away ? 0 : pollen(220, 305, 45),
"cams_europe",
24,
fetchedAt,
fetchedAt,
fetchedAt,
fetchedAt,
]);
}
await bulkInsert(
client,
"environment_contexts",
[
"id",
"user_id",
"date",
"location_encrypted",
"source",
"temp_min",
"temp_max",
"temp_mean",
"apparent_mean",
"sunshine_sec",
"daylight_sec",
"precip_sum",
"pressure_mean",
"pressure_delta",
"humidity_mean",
"cloud_mean",
"weather_code",
"apparent_max",
"pm25_mean",
"pm25_max",
"pm10_mean",
"no2_mean",
"so2_mean",
"co_mean",
"o3_max_8h",
"eaqi_max",
"usaqi_max",
"uv_index_max",
"dust_max",
"aod_max",
"pollen_alder_max",
"pollen_birch_max",
"pollen_grass_max",
"pollen_mugwort_max",
"pollen_olive_max",
"pollen_ragweed_max",
"aq_domain",
"aq_hours",
"aq_fetched_at",
"fetched_at",
"created_at",
"updated_at",
],
envRows,
200,
);
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 conversations ────────────
// Five short, encrypted exchanges spread from today back over a few
// weeks, so the history rail groups them by day the way a used account
// reads, and the thread that opens first (today's) carries a full answer
// with the steps the Coach took. 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 conversations...");
// The provenance uses the contract tokens (`bp`, `resting_hr`, `sleep`):
// the client names an area only through a known token, so a token it
// cannot name never reaches the page. The steps give the answer its
// "Looked at N sources" header the way a live turn would; they carry no
// counts, since a count is the server's tally and the seed does not
// run a tool.
const WINDOW_LABEL = {
last7days: "last 7 days",
last30days: "last 30 days",
last90days: "last 90 days",
} as const;
const seedStep = (
id: string,
tool: string,
domain: string,
domainLabel: string,
window: keyof typeof WINDOW_LABEL = "last30days",
) => ({
id,
tool,
labelKey: "coach.step.readWindow",
label: `Checking: ${domainLabel}, ${WINDOW_LABEL[window]}`,
domain,
window,
status: "done",
});
type SeedStep = ReturnType<typeof seedStep>;
const answer = (
content: string,
window: SeedStep["window"],
steps: SeedStep[],
tokensUsed: number,
) => ({
role: "assistant",
content,
metricSourceJson: JSON.stringify({
windows: [window],
metrics: [...new Set(steps.map((step) => step.domain))],
steps,
}),
tokensUsed,
model: aiModel,
});
const coachConversations: Array<{
title: string;
startedAt: Date;
turns: Array<{
role: string;
content: string;
metricSourceJson?: string;
tokensUsed?: number;
model?: string;
}>;
}> = [
{
title: "Recovery this week",
// Today, shortly before the seed ran, so it is the newest thread.
startedAt: new Date(Date.now() - 50 * 60_000),
turns: [
{
role: "user",
content:
"How well have I been recovering this week, and is there anything I should change?",
},
answer(
"This has been a well-recovered week. You slept close to seven " +
"and a half hours on most nights, with a steady share of deep " +
"sleep, and your resting heart rate stayed in the high fifties, " +
"which is where it has sat for the past month.\n\n" +
"Heart-rate variability tells the same story: it held inside " +
"your usual range on every day this week, and the two harder " +
"runs did not push it down the next morning. Your step count " +
"averaged around nine thousand a day, so the load is there " +
"without tipping into strain.\n\n" +
"Nothing here asks for a change. If you want one thing to keep " +
"an eye on, it is the later bedtimes on the weekend: the two " +
"shortest nights of the week both fell on a Saturday or Sunday.",
"last7days",
[
seedStep("s1", "get_sleep", "sleep", "Sleep", "last7days"),
seedStep(
"s2",
"get_metric_series",
"resting_hr",
"Resting HR",
"last7days",
),
seedStep("s3", "get_metric_series", "hrv", "HRV", "last7days"),
seedStep(
"s4",
"get_metric_series",
"steps",
"Steps",
"last7days",
),
seedStep(
"s5",
"get_workouts",
"workouts",
"Workouts",
"last7days",
),
],
2630,
),
],
},
{
title: "Resting heart rate",
startedAt: daysAgoAt(1, 19, 40),
turns: [
{ role: "user", content: "Is my resting heart rate improving?" },
answer(
"Yes, gradually. Three months ago it sat in the low sixties; over " +
"the last few weeks it has settled in the high fifties and " +
"stayed there. That kind of slow, steady drift usually follows " +
"regular aerobic training, which matches the running in your log.",
"last90days",
[
seedStep(
"s1",
"get_metric_series",
"resting_hr",
"Resting HR",
"last90days",
),
seedStep(
"s2",
"get_workouts",
"workouts",
"Workouts",
"last90days",
),
],
1520,
),
],
},
{
title: "Blood pressure trend",
startedAt: daysAgo(3),
turns: [
{
role: "user",
content: "How has my blood pressure been trending lately?",
},
answer(
"Your blood pressure has come back down nicely. During the stressful " +
"stretch two to three weeks ago your morning readings sat around " +
"144/91 for about ten days; since ramipril went up to 10 mg they " +
"have eased back into the optimal band within about a week, 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.",
"last30days",
[
seedStep("s1", "get_metric_series", "bp", "Blood pressure"),
seedStep("s2", "get_metric_series", "resting_hr", "Resting HR"),
seedStep("s3", "get_sleep", "sleep", "Sleep"),
],
1840,
),
],
},
{
title: "Back to running after the knee",
startedAt: daysAgoAt(9, 7, 55),
turns: [
{
role: "user",
content:
"Am I back to my old activity level since the knee surgery?",
},
answer(
"Yes. Across the last three months you have run, cycled and done " +
"strength sessions three to four times a week, and your daily " +
"steps have climbed from around six and a half thousand to about " +
"nine thousand. Your resting heart rate came down while the load " +
"went up, which suggests the build-up after the arthroscopy was " +
"well paced.",
"last90days",
[
seedStep(
"s1",
"get_metric_series",
"steps",
"Steps",
"last90days",
),
seedStep(
"s2",
"get_workouts",
"workouts",
"Workouts",
"last90days",
),
],
1710,
),
],
},
{
title: "Sleep and mood",
startedAt: daysAgoAt(24, 21, 10),
turns: [
{
role: "user",
content: "Do my sleep and my mood move together?",
},
answer(
"Somewhat. On the mornings after nights shorter than about six " +
"and a half hours, your mood entries were a little lower on " +
"average. It is a loose pattern across a month of data, worth " +
"watching rather than a cause and effect.",
"last30days",
[
seedStep("s1", "get_sleep", "sleep", "Sleep"),
seedStep("s2", "get_metric_series", "mood", "Mood"),
],
1390,
),
],
},
];
for (const convo of coachConversations) {
const convoId = cuid();
const lastAt = new Date(
convo.startedAt.getTime() + (convo.turns.length - 1) * 60_000,
);
// The title is sealed the way the app writes it since v1.39.3. A
// readable `title` is picked up by the boot backfill, whose update
// also moves `updated_at` to the moment it ran, and every thread then
// sorted under "Today".
await client.query(
`INSERT INTO coach_conversations (id, user_id, title_encrypted, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)`,
[
convoId,
userId,
Buffer.from(encryptToBytes(convo.title)),
convo.startedAt,
lastAt,
],
);
for (let t = 0; t < convo.turns.length; t++) {
const turn = convo.turns[t];
const ts = new Date(convo.startedAt.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, tokens_used, model, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
cuid(),
convoId,
turn.role,
encrypted,
turn.metricSourceJson ?? null,
turn.role === "assistant" ? aiProvider : null,
turn.role === "assistant" ? "demo" : null,
turn.tokensUsed ?? null,
turn.model ?? 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 tag for the rows that record one (the cached
// briefing, the narratives). The text is what renders; the tag is
// provenance only.
const bakedProvider = "anthropic";
// ── Comprehensive insight + daily briefing ──
// Stored sealed on users.insights_cached_text_encrypted (JSON), written
// the way a generation writes it (`briefing-request/commit.ts`): the
// payload stamped with its generation moment, the cache stamp, and the
// language tag. Every briefing reader serves `dailyBriefing` only when
// that `briefingGeneratedAt` falls on the reader's today, so a payload
// without it reads as text of unknown age: the advisor card and the
// dashboard headline stay empty and a warm is asked for, which on the
// demo can only fail against the placeholder key. 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: {
// No salutation: the hero above it already greets by the hour, and
// a demo filmed in the evening read "Good evening" over "Good morning".
paragraph:
"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 is back in the optimal range.",
detail:
"After a higher stretch two to three weeks ago and a ramipril " +
"dose increase, your readings have come back down below 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: "After a higher stretch and a ramipril dose increase, your systolic has come back down into the optimal band.",
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.",
},
};
const briefingWrittenAt = new Date();
await client.query(
`UPDATE users
SET insights_cached_text_encrypted = $2,
insights_cached_at = $5,
insights_cached_locale = 'en',
insights_snapshot_hash = $3,
insights_briefing_reroll_date = $4
WHERE id = $1`,
[
userId,
// Sealed like every write since v1.43.7.
Buffer.from(
sealInsightsCachedText(
JSON.stringify(
withBriefingGeneratedAt(comprehensiveInsight, briefingWrittenAt),
),
),
),
// 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,
briefingWrittenAt,
],
);
// ── Per-metric status cards ──
// Each card is an insight_status_caches row keyed (user, metric, locale),
// the note encrypted with the app's codec. The read serves a note only
// when its date_key equals today's key, so every card is stamped with
// today's key and reads as a current assessment. The medication card also
// carries its (empty) per-medication list. 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 back around 118/76 this week. Two to three " +
"weeks ago it climbed to about 144/91 and held there for about ten " +
"days; after ramipril went up to 10 mg it came down over the " +
"following days and now sits inside the optimal band again. 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) {
await client.query(
`INSERT INTO insight_status_caches
(id, user_id, metric, locale, text_encrypted, items_encrypted,
snapshot_hash, date_key, generated_at, created_at, updated_at)
VALUES ($1, $2, $3, 'en', $4, $5, $6, $7, NOW(), NOW(), NOW())
ON CONFLICT (user_id, metric, locale) DO UPDATE SET
text_encrypted = EXCLUDED.text_encrypted,
items_encrypted = EXCLUDED.items_encrypted,
snapshot_hash = EXCLUDED.snapshot_hash,
date_key = EXCLUDED.date_key,
generated_at = EXCLUDED.generated_at,
retry_at = NULL,
negative_reason = NULL,
updated_at = NOW()`,
[
cuid(),
userId,
card.scope,
Buffer.from(encryptToBytes(card.text)),
card.scope === "medication-compliance"
? Buffer.from(encryptToBytes("[]"))
: null,
`demo-baked-${card.scope}`,
todayKey,
],
);
}
// ── 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 had one higher stretch, " +
"came back down after the ramipril dose went up and now sits in the " +
"optimal band again, 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'`,
);
// The recovery kit is confirmed for the keys the demo runs with, the same
// entries `POST /api/admin/recovery-kit/confirm` writes (key ids and
// public fingerprints, never a key). The demo account is an admin, so an
// unconfirmed kit put a "Save your recovery kit" banner above the
// dashboard for every visitor, about keys no visitor can reach. The seed
// writes no backup copies, so no retired key is needed.
const kitEntries = [
...applicationKitKeys({
activeKeyId: getActiveKeyId(),
configuredKeyIds: getConfiguredKeyIds(),
fingerprintOf: getKeyFingerprint,
needs: {
stored: [],
unrecorded: { copies: 0, oldestAt: null },
offhost: [],
dumps: [],
offhostExpirationDays: null,
retiredKeysStillNeeded: [],
},
now: new Date(),
}),
...backupKitKeys(
process.env.BACKUP_ENCRYPTION_KEY,
process.env.BACKUP_ENCRYPTION_PREVIOUS_KEYS,
),
...tokenKitKeys(process.env.API_TOKEN_HMAC_KEY),
].map(({ kind, id, fingerprint }) => ({ kind, id, fingerprint }));
await client.query(
`INSERT INTO recovery_kit_confirmations (id, confirmed_at, confirmed_by_user_id, entries)
VALUES ('singleton', NOW(), $1, $2::jsonb)
ON CONFLICT (id) DO UPDATE SET confirmed_at = EXCLUDED.confirmed_at,
confirmed_by_user_id = EXCLUDED.confirmed_by_user_id,
entries = EXCLUDED.entries`,
[userId, JSON.stringify(kitEntries)],
);
// ── 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, waist`,
);
console.log(
` Body composition: fat/lean/muscle mass, water, bone, visceral fat, BMI`,
);
console.log(
` Blood glucose: ${glucoseReadings.length} spot readings/day (fasting + post-meal + bedtime)`,
);
console.log(
` CGM: Nightscout-style stream, every ${cgmStepMin} min over ${cgmDays} days`,
);
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(` Wellbeing screeners: 6 (WHO-5, PHQ-9, GAD-7)`);
console.log(` Vorsorge reminders: 2 (dental, annual physical)`);
console.log(
` Lab panels: ${labResults.length} rows across 2 panels (incl. ${recentQual.length} qualitative)`,
);
console.log(
` Illness: resolved cold (day-logs) + recurring condition with a resolved flare`,
);
console.log(
` Cycle: ${cycleLengths.length} cycles with BBT, flow, mucus/OPK, symptoms + a forecast`,
);
console.log(
` Visits: knee arthroscopy (2 linked PDFs) + follow-up, a blood-pressure visit, check-up booked for tomorrow`,
);
console.log(` Coach: ${coachConversations.length} conversations`);
console.log(
` Baked AI texts: comprehensive + daily briefing, ${statusCards.length} status cards, ${periodNarratives.length} period narratives (en)`,
);
console.log(` Achievements: ${achievements.length}`);
console.log(
` History: ${historyRows.length} readings over ${HISTORY_DAYS} days, ${lifeEvents.length} life events, ${envRows.length} environment days`,
);
} catch (err) {
await client.query("ROLLBACK");
console.error("Seed failed:", err);
throw err;
} finally {
client.release();
await pool.end();
}
}
if (
process.argv[1] &&
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))
) {
seed();
}