mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge pull request #1165 from MBombeck/feat/demo-seed-v1402
fix(demo): seed the surfaces the start page and Coach now show
This commit is contained in:
1 file changed
+526
-85
+526
-85
@@ -32,12 +32,18 @@
|
||||
* - 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
|
||||
* 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
|
||||
* - An AI-configured Coach with one short sample conversation
|
||||
* - App settings (registration disabled, English locale)
|
||||
* - Two practitioners, a knee arthroscopy with its operative report and
|
||||
* discharge letter linked (small generated PDFs), its follow-up visit, 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, the key-backup
|
||||
* step confirmed for the key the demo runs with)
|
||||
*
|
||||
* 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
|
||||
@@ -54,15 +60,21 @@
|
||||
* score can show.
|
||||
*
|
||||
* Usage: npx tsx scripts/seed-demo.ts
|
||||
* Requires DATABASE_URL env var. The Coach sample conversation additionally
|
||||
* 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 pg from "pg";
|
||||
import { encryptToBytes } from "../src/lib/ai/coach/bytes-codec";
|
||||
import { encrypt } from "../src/lib/crypto";
|
||||
import {
|
||||
encrypt,
|
||||
encryptBytes,
|
||||
getActiveKeyId,
|
||||
getKeyFingerprint,
|
||||
} from "../src/lib/crypto";
|
||||
import {
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
serializeDashboardLayout,
|
||||
@@ -80,6 +92,15 @@ if (!DATABASE_URL) {
|
||||
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 ──────────────────────────────────────
|
||||
@@ -198,6 +219,55 @@ function randomWalk(
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────
|
||||
|
||||
async function seed() {
|
||||
@@ -294,7 +364,6 @@ async function seed() {
|
||||
]);
|
||||
const demoDashboardLayout = serializeDashboardLayout({
|
||||
...DEFAULT_DASHBOARD_LAYOUT,
|
||||
heroVisible: true,
|
||||
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) =>
|
||||
SHOWCASE_TILE_IDS.has(w.id) ? { ...w, tileVisible: true } : w,
|
||||
),
|
||||
@@ -342,16 +411,22 @@ async function seed() {
|
||||
);
|
||||
// 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 → ~7h45m
|
||||
// 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,
|
||||
465,
|
||||
460,
|
||||
span,
|
||||
25,
|
||||
12,
|
||||
VALUE_RANGES.SLEEP_DURATION,
|
||||
);
|
||||
// Steps: 6500 → a solid ~9000
|
||||
const steps = randomWalk(6500, 9000, span, 1500);
|
||||
for (const series of [weights, sysBP, diaBP, pulse, restingHr]) {
|
||||
settleLatest(series);
|
||||
}
|
||||
|
||||
for (let i = 0; i < span; i++) {
|
||||
const date = daysAgo(days - i);
|
||||
@@ -603,7 +678,12 @@ async function seed() {
|
||||
{ context: "POSTPRANDIAL", hour: 19, base: 118, jitter: 12 },
|
||||
{ context: "BEDTIME", hour: 22, base: 99, jitter: 6 },
|
||||
];
|
||||
for (let i = 0; i < span; i++) {
|
||||
// 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);
|
||||
@@ -632,7 +712,9 @@ async function seed() {
|
||||
console.log("Creating continuous glucose (CGM) stream...");
|
||||
const cgmDays = 14;
|
||||
const cgmStepMin = 15;
|
||||
for (let d = cgmDays - 1; d >= 0; d--) {
|
||||
// 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
|
||||
@@ -704,6 +786,9 @@ async function seed() {
|
||||
);
|
||||
// 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);
|
||||
@@ -1135,6 +1220,11 @@ async function seed() {
|
||||
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)
|
||||
@@ -1289,14 +1379,17 @@ async function seed() {
|
||||
// ── 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
|
||||
// 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> => {
|
||||
@@ -1430,27 +1523,35 @@ async function seed() {
|
||||
});
|
||||
}
|
||||
|
||||
// (2) Active chronic condition + a recent flare hanging off it.
|
||||
// (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', 'CHRONIC_ONGOING', $3, NULL, $4, NOW(), NOW())`,
|
||||
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 still-open bout this past week referencing the parent.
|
||||
// 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, NULL, $4, $5, NOW(), NOW())`,
|
||||
VALUES ($1, $2, 'Pollen flare', 'ALLERGY', 'FLARE', $3, $4, $5, $6, NOW(), NOW())`,
|
||||
[
|
||||
flareId,
|
||||
userId,
|
||||
daysAgo(5),
|
||||
daysAgo(FLARE_ONSET_DAYS_AGO),
|
||||
daysAgo(FLARE_ONSET_DAYS_AGO - 7),
|
||||
allergyId,
|
||||
encBytes("High pollen count this week — symptoms back."),
|
||||
],
|
||||
@@ -1462,7 +1563,7 @@ async function seed() {
|
||||
symptoms: Array<{ key: string; severity: number }>;
|
||||
}> = [
|
||||
{
|
||||
offset: 5,
|
||||
offset: FLARE_ONSET_DAYS_AGO,
|
||||
impact: 1,
|
||||
note: "Itchy eyes and sneezing started today.",
|
||||
symptoms: [
|
||||
@@ -1471,7 +1572,7 @@ async function seed() {
|
||||
],
|
||||
},
|
||||
{
|
||||
offset: 4,
|
||||
offset: FLARE_ONSET_DAYS_AGO - 1,
|
||||
impact: 1,
|
||||
note: "Pollen high again — runny nose all morning.",
|
||||
symptoms: [
|
||||
@@ -1480,7 +1581,7 @@ async function seed() {
|
||||
],
|
||||
},
|
||||
{
|
||||
offset: 2,
|
||||
offset: FLARE_ONSET_DAYS_AGO - 3,
|
||||
impact: 1,
|
||||
note: "Antihistamine taking the edge off.",
|
||||
symptoms: [
|
||||
@@ -1489,7 +1590,7 @@ async function seed() {
|
||||
],
|
||||
},
|
||||
{
|
||||
offset: 0,
|
||||
offset: FLARE_ONSET_DAYS_AGO - 6,
|
||||
impact: 1,
|
||||
note: "Still sniffly but manageable.",
|
||||
symptoms: [{ key: "runny_nose", severity: 1 }],
|
||||
@@ -1705,6 +1806,142 @@ async function seed() {
|
||||
],
|
||||
);
|
||||
|
||||
// ── 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";
|
||||
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.",
|
||||
});
|
||||
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 ─────────────────────────
|
||||
console.log("Creating achievements...");
|
||||
|
||||
@@ -1752,93 +1989,280 @@ async function seed() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Coach sample conversation ─────────────
|
||||
// A short, encrypted two-turn exchange so the Coach surface shows real
|
||||
// content. 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 conversation...");
|
||||
const convoId = cuid();
|
||||
const convoStart = daysAgo(3);
|
||||
await client.query(
|
||||
`INSERT INTO coach_conversations (id, user_id, title, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $4)`,
|
||||
[convoId, userId, "Blood pressure trend", convoStart],
|
||||
);
|
||||
// ── 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 3 sources" header the way a live turn would; they carry no
|
||||
// "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}, last 30 days`,
|
||||
label: `Checking: ${domainLabel}, ${WINDOW_LABEL[window]}`,
|
||||
domain,
|
||||
window: "last30days",
|
||||
window,
|
||||
status: "done",
|
||||
});
|
||||
const coachTurns: Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
metricSourceJson?: string;
|
||||
tokensUsed?: number;
|
||||
model?: string;
|
||||
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;
|
||||
}>;
|
||||
}> = [
|
||||
{
|
||||
role: "user",
|
||||
content: "How has my blood pressure been trending lately?",
|
||||
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,
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"Your blood pressure has been settling nicely. Over the last few " +
|
||||
"weeks the morning readings have drifted into the optimal band, 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.",
|
||||
metricSourceJson: JSON.stringify({
|
||||
windows: ["last30days"],
|
||||
metrics: ["bp", "resting_hr", "sleep"],
|
||||
steps: [
|
||||
seedStep("s1", "get_metric_series", "bp", "Blood pressure"),
|
||||
seedStep("s2", "get_metric_series", "resting_hr", "Resting HR"),
|
||||
seedStep("s3", "get_sleep", "sleep", "Sleep"),
|
||||
],
|
||||
}),
|
||||
tokensUsed: 1840,
|
||||
model: aiModel,
|
||||
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 been settling nicely. Over the last few " +
|
||||
"weeks the morning readings have drifted into the optimal band, 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 (let t = 0; t < coachTurns.length; t++) {
|
||||
const turn = coachTurns[t];
|
||||
const ts = new Date(convoStart.getTime() + t * 60_000);
|
||||
const encrypted = Buffer.from(encryptToBytes(turn.content));
|
||||
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_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)`,
|
||||
`INSERT INTO coach_conversations (id, user_id, title_encrypted, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[
|
||||
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,
|
||||
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 ────────────────
|
||||
@@ -2240,10 +2664,24 @@ async function seed() {
|
||||
|
||||
// ── App Settings ─────────────────────────
|
||||
console.log("Creating app settings...");
|
||||
// The key-backup step is confirmed for the key the demo runs with, the
|
||||
// same fields `POST /api/admin/encryption/key-backup/confirm` writes (key
|
||||
// id + its public fingerprint, never the key). The demo account is an
|
||||
// admin, so an unconfirmed step put a "Back up your encryption key"
|
||||
// banner above the dashboard for every visitor, about a key no visitor
|
||||
// can reach.
|
||||
const activeKeyId = getActiveKeyId();
|
||||
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'`,
|
||||
`INSERT INTO app_settings (id, registration_enabled, default_locale,
|
||||
encryption_key_backup_confirmed_at, encryption_key_backup_confirmed_key_id,
|
||||
encryption_key_backup_confirmed_fingerprint, encryption_key_backup_confirmed_by_user_id)
|
||||
VALUES ('singleton', false, 'en', NOW(), $1, $2, $3)
|
||||
ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en',
|
||||
encryption_key_backup_confirmed_at = EXCLUDED.encryption_key_backup_confirmed_at,
|
||||
encryption_key_backup_confirmed_key_id = EXCLUDED.encryption_key_backup_confirmed_key_id,
|
||||
encryption_key_backup_confirmed_fingerprint = EXCLUDED.encryption_key_backup_confirmed_fingerprint,
|
||||
encryption_key_backup_confirmed_by_user_id = EXCLUDED.encryption_key_backup_confirmed_by_user_id`,
|
||||
[activeKeyId, getKeyFingerprint(activeKeyId), userId],
|
||||
);
|
||||
|
||||
// ── Audit Log (some login entries) ──────
|
||||
@@ -2292,12 +2730,15 @@ async function seed() {
|
||||
` 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`,
|
||||
` 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(` Coach: 1 conversation (${coachTurns.length} messages)`);
|
||||
console.log(
|
||||
` Visits: knee arthroscopy (2 linked PDFs) + follow-up, 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)`,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user