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);