From 145d138c66f4eade8f8e41f34b624cd01012a9ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 11:15:56 +0200 Subject: [PATCH] chore(demo): seed a multi-year history for the timeline and day view The demo account carried 90 days, which leaves the timeline's All zoom and the day view with nothing to show before last quarter. The seed now writes about four and a half years before that window: blood pressure in bursts with a silent quarter and a thin one, weekly weight, phone steps, watch resting heart rate, HRV and sleep from the third year, yearly blood panels with HbA1c, lipids and hemoglobin, a resolved chronic condition and three infections, ramipril with a stated start, a dose change and a sick-day pause, a finished antibiotic course, flu shots and a Tdap booster, visits, life events at day, month and year precision, a sealed home location with a holiday period, and the stored weather, air quality and pollen for every day. The timeline module is switched on. All of it is invented and relative to the seed run; rows are written in chunks so the seed stays quick. --- scripts/seed-demo.ts | 952 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 944 insertions(+), 8 deletions(-) diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 1097ed149..941fffa0e 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -44,6 +44,14 @@ * today back over a few weeks * - App settings (registration disabled, English locale, the key-backup * step confirmed for the key 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 @@ -75,6 +83,7 @@ import { getActiveKeyId, getKeyFingerprint, } from "../src/lib/crypto"; +import { ENVIRONMENT_LOCATION_AAD } from "../src/lib/crypto/encrypted-columns"; import { DEFAULT_DASHBOARD_LAYOUT, serializeDashboardLayout, @@ -268,6 +277,137 @@ function settleLatest(values: number[], lookback = 28): void { 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 { + 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, + ); + } +} + +/** 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 ───────────────────────────────────────── async function seed() { @@ -1010,12 +1150,21 @@ async function seed() { // ── Medications ─────────────────────────── console.log("Creating medications..."); - // Medication 1: Ramipril (blood pressure) + // Medication 1: Ramipril (blood pressure). Started about three years + // back at 2.5 mg and raised to 5 mg a year later; 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, created_at, updated_at) - VALUES ($1, $2, 'Ramipril', '5mg', true, true, $3, $3)`, - [med1Id, userId, daysAgo(120)], + `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, starts_on, created_at, updated_at) + VALUES ($1, $2, 'Ramipril', '5mg', true, true, $3, $4, $4)`, + [ + med1Id, + userId, + formatDate(daysAgo(RAMIPRIL_START_DAYS_AGO)), + daysAgo(RAMIPRIL_START_DAYS_AGO), + ], ); const sched1Id = cuid(); await client.query( @@ -1027,9 +1176,14 @@ async function seed() { // Medication 2: Vitamin D3 const med2Id = cuid(); await client.query( - `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at) - VALUES ($1, $2, 'Vitamin D3', '2000 IU', true, true, $3, $3)`, - [med2Id, userId, daysAgo(90)], + `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( @@ -1829,7 +1983,7 @@ async function seed() { const insertEncounter = async (params: { occurredAt: Date; status: "DONE" | "PLANNED"; - kind: "ROUTINE" | "PROCEDURE"; + kind: "ROUTINE" | "PROCEDURE" | "PREVENTIVE" | "SPECIALIST" | "ACUTE"; practitionerId: string; reason: string; outcome?: string; @@ -1943,6 +2097,785 @@ async function seed() { } // ── 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(); + 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. + for (const [n, value] of [ + [RAMIPRIL_START_DAYS_AGO, 2.5], + [RAMIPRIL_DOSE_UP_DAYS_AGO, 5], + ] 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 }> = + [ + { + 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> = { + 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( + "Blood panel", + { ...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 (readable columns null). + const homeSince = daysAgoAt(HISTORY_DAYS, 12); + await client.query( + `UPDATE users + SET module_preferences_json = $2, + home_lat = NULL, home_lon = NULL, home_label = NULL, + 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 late in summer, a little ragweed 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, + null, + null, + null, + 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(215, 290, 34), + "cams_europe", + 24, + fetchedAt, + fetchedAt, + fetchedAt, + fetchedAt, + ]); + } + await bulkInsert( + client, + "environment_contexts", + [ + "id", + "user_id", + "date", + "lat", + "lon", + "location_label", + "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 @@ -2743,6 +3676,9 @@ async function seed() { ` 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);