mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
4088 lines
158 KiB
TypeScript
4088 lines
158 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|