mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge w11b/demoseed into release/v1.19.0
This commit is contained in:
commit
328e1b4544
1 file changed
+566
-24
+566
-24
@@ -14,7 +14,8 @@
|
||||
* - 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)
|
||||
* 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
|
||||
@@ -25,8 +26,13 @@
|
||||
* compliance, with today scheduled on-track (taken or not-yet-due)
|
||||
* - 90 days of mood entries
|
||||
* - Vorsorge (preventive-care) reminders — upcoming dental + annual physical
|
||||
* - One lab panel of biomarkers in healthy ranges
|
||||
* - One resolved illness episode in the past
|
||||
* - 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 an active chronic condition carrying a recent flare
|
||||
* - 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
|
||||
* - An AI-configured Coach with one short sample conversation
|
||||
* - App settings (registration disabled, English locale)
|
||||
*
|
||||
@@ -181,7 +187,14 @@ async function seed() {
|
||||
await client.query("DELETE FROM coach_conversations");
|
||||
await client.query("DELETE FROM consent_receipts");
|
||||
await client.query("DELETE FROM insight_narratives");
|
||||
await client.query("DELETE FROM illness_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");
|
||||
@@ -469,6 +482,50 @@ async function seed() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
for (let d = cgmDays - 1; d >= 0; 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
|
||||
@@ -979,18 +1036,27 @@ async function seed() {
|
||||
[cuid(), userId, daysAgo(275), physicalNextDue],
|
||||
);
|
||||
|
||||
// ── Lab panel (biomarkers in healthy ranges) ──
|
||||
// One grouped panel taken ~10 days ago, every analyte comfortably inside
|
||||
// its reference range, so the labs surface reads clean.
|
||||
console.log("Creating lab panel...");
|
||||
const panelTakenAt = daysAgo(10);
|
||||
const labResults: Array<{
|
||||
// ── 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;
|
||||
}> = [
|
||||
};
|
||||
type QualLab = { analyte: string; valueText: string; unit: string };
|
||||
|
||||
const recentPanelAt = daysAgo(10);
|
||||
const recentQuant: QuantLab[] = [
|
||||
{
|
||||
analyte: "Total Cholesterol",
|
||||
value: 178,
|
||||
@@ -1012,32 +1078,498 @@ async function seed() {
|
||||
{ analyte: "Vitamin D", value: 42, unit: "ng/mL", low: 30, high: 100 },
|
||||
{ analyte: "TSH", value: 1.8, unit: "mIU/L", low: 0.4, high: 4.0 },
|
||||
{ analyte: "Ferritin", value: 120, unit: "ng/mL", low: 30, high: 400 },
|
||||
{ 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 },
|
||||
];
|
||||
for (const lab of labResults) {
|
||||
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, lab: QuantLab, takenAt: Date) => {
|
||||
await client.query(
|
||||
`INSERT INTO lab_results (id, user_id, panel, analyte, value, unit, reference_low, reference_high, taken_at, source, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Annual blood panel', $3, $4, $5, $6, $7, $8, 'MANUAL', NOW(), NOW())`,
|
||||
`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, $5, NULL, $6, $7, $8, $9, 'MANUAL', NOW(), NOW())`,
|
||||
[
|
||||
cuid(),
|
||||
userId,
|
||||
panel,
|
||||
lab.analyte,
|
||||
lab.value,
|
||||
lab.unit,
|
||||
lab.low,
|
||||
lab.high,
|
||||
panelTakenAt,
|
||||
takenAt,
|
||||
],
|
||||
);
|
||||
};
|
||||
const insertQual = async (panel: string, 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],
|
||||
);
|
||||
};
|
||||
|
||||
for (const lab of recentQuant) {
|
||||
await insertQuant("Annual blood panel", lab, recentPanelAt);
|
||||
}
|
||||
for (const lab of recentQual) {
|
||||
await insertQual("Annual blood panel", lab, recentPanelAt);
|
||||
}
|
||||
for (const lab of olderQuant) {
|
||||
await insertQuant("Blood panel (last year)", 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) an active chronic condition (seasonal allergic
|
||||
// rhinitis) carrying a recent 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...");
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Resolved illness episode ──────────────
|
||||
// A short cold ~7 weeks ago, fully recovered, so the illness journal shows
|
||||
// a clean "resolved" entry rather than an active sickness.
|
||||
console.log("Creating resolved illness episode...");
|
||||
// (2) Active chronic condition + a recent flare hanging off it.
|
||||
const allergyId = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Common cold', 'INFECTION', 'ACUTE', $3, $4, NOW(), NOW())`,
|
||||
[cuid(), userId, daysAgo(49), daysAgo(43)],
|
||||
`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', 'CHRONIC_ONGOING', $3, NULL, $4, NOW(), NOW())`,
|
||||
[
|
||||
allergyId,
|
||||
userId,
|
||||
daysAgo(400),
|
||||
encBytes("Tree pollen each spring. Antihistamine helps."),
|
||||
],
|
||||
);
|
||||
// The flare: a still-open bout this past week referencing the parent.
|
||||
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, NULL, $4, $5, NOW(), NOW())`,
|
||||
[
|
||||
flareId,
|
||||
userId,
|
||||
daysAgo(5),
|
||||
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: 5,
|
||||
impact: 1,
|
||||
note: "Itchy eyes and sneezing started today.",
|
||||
symptoms: [
|
||||
{ key: "sneezing", severity: 2 },
|
||||
{ key: "runny_nose", severity: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offset: 4,
|
||||
impact: 1,
|
||||
note: "Pollen high again — runny nose all morning.",
|
||||
symptoms: [
|
||||
{ key: "runny_nose", severity: 3 },
|
||||
{ key: "sneezing", severity: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offset: 2,
|
||||
impact: 1,
|
||||
note: "Antihistamine taking the edge off.",
|
||||
symptoms: [
|
||||
{ key: "stuffy_nose", severity: 2 },
|
||||
{ key: "fatigue", severity: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offset: 0,
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Achievements ─────────────────────────
|
||||
@@ -1566,7 +2098,10 @@ async function seed() {
|
||||
` Body composition: fat/lean/muscle mass, water, bone, visceral fat, BMI`,
|
||||
);
|
||||
console.log(
|
||||
` Blood glucose: ${glucoseReadings.length} readings/day (fasting + post-meal + bedtime)`,
|
||||
` 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`,
|
||||
@@ -1583,8 +2118,15 @@ async function seed() {
|
||||
);
|
||||
console.log(` Mood: ~${Math.round(span * 0.95)} entries`);
|
||||
console.log(` Vorsorge reminders: 2 (dental, annual physical)`);
|
||||
console.log(` Lab panel: ${labResults.length} biomarkers`);
|
||||
console.log(` Illness: 1 resolved episode`);
|
||||
console.log(
|
||||
` Lab panels: ${labResults.length} rows across 2 panels (incl. ${recentQual.length} qualitative)`,
|
||||
);
|
||||
console.log(
|
||||
` Illness: resolved cold (day-logs) + active chronic condition with a flare`,
|
||||
);
|
||||
console.log(
|
||||
` Cycle: ${cycleLengths.length} cycles with BBT, flow, mucus/OPK, symptoms + a forecast`,
|
||||
);
|
||||
console.log(` Coach: 1 conversation (${coachTurns.length} messages)`);
|
||||
console.log(
|
||||
` Baked AI texts: comprehensive + daily briefing, ${statusCards.length} status cards, ${periodNarratives.length} period narratives (en)`,
|
||||
|
||||
Reference in new issue
Block a user