fix(demo): consent the demo user and anchor today's doses to their slots

Mint an active AI-processing consent receipt for the demo user so the
Coach renders its sample conversation without a consent-accept write the
read-only tenant rejects. The receipt is kind `ai_full`, the master grant
the Coach and Insights gates both accept; the read path only checks an
active row of that kind, so a placeholder artefact suffices. Add a
consent_receipts reset to keep the seed idempotent.

Anchor every taken intake's scheduled_for on its canonical dose-slot
instant (08:00 morning, 20:00 evening, Europe/Berlin) with taken_at set
slightly later, so the row is slot-anchored and resolves its slot within
the dashboard's resolution radius. Previously today's morning rows used
scheduled_for = taken_at, which the scheduler reads as an ad-hoc take that
only resolves within a minute of its own instant, leaving the 08:00 slot
reading overdue while the take floated beside it. Today's picture now reads
on-track at any daytime clock, with the evening dose pending and not yet due.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 09:46:43 +02:00
1 parent 69d03c45c4
commit 9b8cd819f4
1 file changed
+111 -24
+111 -24
View File
@@ -89,6 +89,48 @@ function formatDate(d: Date): string {
return d.toISOString().split("T")[0];
}
// The UTC instant of `hh:mm` Europe/Berlin local on the Berlin calendar day
// that contains `ref`. This mirrors `localHmAsUtc` (src/lib/tz/local-day.ts):
// the medication scheduling engine mints today's dose-slot anchor as the UTC
// instant of the schedule's window_start in the user's timezone (the demo
// user is Europe/Berlin). The dashboard's slot-resolution matches a taken
// intake row to a slot only when the row's `scheduled_for` sits on (or, for a
// slot-anchored row, within ±6h of) that canonical instant. We must compute
// the same instant here — independent of the seed host's own clock zone — so
// today's taken rows satisfy the 08:00 slot rather than floating beside it.
function berlinHmAsUtc(hour: number, minute = 0, ref: Date = new Date()): Date {
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const parts = Object.fromEntries(
fmt.formatToParts(ref).map((p) => [p.type, p.value]),
);
const y = Number(parts.year);
const mo = Number(parts.month);
const d = Number(parts.day);
// `hour` can come back as "24" at midnight in some runtimes; normalise.
const refHour = Number(parts.hour) % 24;
const localTargetAsUtc = Date.UTC(y, mo - 1, d, hour, minute, 0, 0);
const localNowAsUtc = Date.UTC(
y,
mo - 1,
d,
refHour,
Number(parts.minute),
Number(parts.second),
);
const offsetMs =
Math.round((localNowAsUtc - ref.getTime()) / 60000) * 60000;
return new Date(localTargetAsUtc - offsetMs);
}
// The day key the insight read paths compare against. The per-metric status
// cards stamp `dateKey = toBerlinDayKey(now)` (src/lib/tz/resolver.ts) and the
// read serves a row only when its `dateKey` equals today's Berlin key. The
@@ -138,6 +180,7 @@ async function seed() {
console.log("Cleaning existing data...");
await client.query("DELETE FROM coach_messages");
await client.query("DELETE FROM coach_conversations");
await client.query("DELETE FROM consent_receipts");
await client.query("DELETE FROM insight_narratives");
await client.query("DELETE FROM illness_episodes");
await client.query("DELETE FROM lab_results");
@@ -744,40 +787,49 @@ async function seed() {
for (let i = 0; i < days; i++) {
const date = daysAgo(days - i);
// Anchor each historical intake's scheduled_for on its med's canonical
// Berlin dose-slot for that day (08:00 morning, 20:00 evening) — the
// same instant the scheduling engine attributes a take to — so taken
// rows are slot-anchored (scheduled_for ≠ taken_at, resolved within the
// ±6h radius) and skips land exactly on the slot. Anchoring on the slot
// rather than a random morning hour keeps the compliance rollups and the
// overdue search reading these past days as resolved.
const morningSlotFor = berlinHmAsUtc(8, 0, date);
const eveningSlotFor = berlinHmAsUtc(20, 0, date);
// Ramipril: 98% compliance (miss ~1 every 50 days)
const takeMed1 = Math.random() > 0.02;
if (takeMed1) {
const takenTime = new Date(date);
takenTime.setHours(8, Math.floor(Math.random() * 45), 0);
const takenTime = new Date(morningSlotFor);
takenTime.setMinutes(takenTime.getMinutes() + Math.floor(Math.random() * 45) + 1);
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`,
[cuid(), userId, med1Id, date, takenTime],
[cuid(), userId, med1Id, morningSlotFor, takenTime],
);
} else {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4, $4)`,
[cuid(), userId, med1Id, date],
[cuid(), userId, med1Id, morningSlotFor],
);
}
// Vitamin D3: 95% compliance
const takeMed2 = Math.random() > 0.05;
if (takeMed2) {
const takenTime = new Date(date);
takenTime.setHours(8, Math.floor(5 + Math.random() * 50), 0);
const takenTime = new Date(morningSlotFor);
takenTime.setMinutes(takenTime.getMinutes() + Math.floor(5 + Math.random() * 50) + 1);
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`,
[cuid(), userId, med2Id, date, takenTime],
[cuid(), userId, med2Id, morningSlotFor, takenTime],
);
} else {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4, $4)`,
[cuid(), userId, med2Id, date],
[cuid(), userId, med2Id, morningSlotFor],
);
}
@@ -785,46 +837,81 @@ async function seed() {
if (i >= 30) {
const takeMed3 = Math.random() > 0.06;
if (takeMed3) {
const takenTime = new Date(date);
takenTime.setHours(20, Math.floor(Math.random() * 60), 0);
const takenTime = new Date(eveningSlotFor);
takenTime.setMinutes(takenTime.getMinutes() + Math.floor(Math.random() * 60) + 1);
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`,
[cuid(), userId, med3Id, date, takenTime],
[cuid(), userId, med3Id, eveningSlotFor, takenTime],
);
} else {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4, $4)`,
[cuid(), userId, med3Id, date],
[cuid(), userId, med3Id, eveningSlotFor],
);
}
}
}
// ── Today's doses (on-track, no overdue) ──
// Morning meds: taken inside their 08:00–10:00 window. Evening Magnesium:
// a pending row anchored on tonight's 20:00 slot, never auto-missed — it
// reads as "not yet due" regardless of the seed wall-clock, and the
// compliance engine excludes a plain pending row from the denominator, so
// "today" never shows 0% or an overdue dose.
// The dashboard today-tile attributes a taken intake to a computed dose
// slot, where the slot's canonical instant is the schedule's window_start
// in the user's timezone (08:00 Europe/Berlin for the morning meds). A
// taken row only RESOLVES that slot when it is "slot-anchored"
// (scheduled_for ≠ taken_at — then a ±6h radius applies); a row whose
// scheduled_for equals taken_at to the millisecond is treated as an
// ad-hoc take that only resolves a slot within ±60s, so an 08:30 ad-hoc
// row leaves the 08:00 slot reading "overdue" while the take floats beside
// it (the "2/6 today · overdue" screenshot). We therefore anchor
// scheduled_for on the EXACT canonical slot instant (08:00 Berlin) and set
// taken_at slightly later (08:30) so the row is slot-anchored and resolves
// the morning slot cleanly. Both morning meds taken; evening Magnesium is
// a pending row anchored on tonight's 20:00 Berlin slot — its anchor is in
// the future until 20:00 local, so the overdue search never fires on it
// and the compliance engine excludes a plain pending row from the
// denominator. The result reads on-track at any daytime screenshot clock.
console.log("Creating today's on-track doses...");
const todayMorning = daysAgoAt(0, 8, 30);
const morningSlot = berlinHmAsUtc(8, 0); // canonical 08:00 slot instant
const morningTaken = berlinHmAsUtc(8, 30); // taken inside the window
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $4, false, 'WEB', $4, $4)`,
[cuid(), userId, med1Id, todayMorning],
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`,
[cuid(), userId, med1Id, morningSlot, morningTaken],
);
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $4, false, 'WEB', $4, $4)`,
[cuid(), userId, med2Id, todayMorning],
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`,
[cuid(), userId, med2Id, morningSlot, morningTaken],
);
const todayEvening = daysAgoAt(0, 20, 0);
const eveningSlot = berlinHmAsUtc(20, 0); // canonical 20:00 slot instant
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, auto_missed, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULL, false, false, 'WEB', $4, $4)`,
[cuid(), userId, med3Id, todayEvening],
[cuid(), userId, med3Id, eveningSlot],
);
// ── AI-processing consent receipt ─────────
// The Coach surface is gated behind an active AI-consent receipt whenever
// the deployment resolves a server-managed (operator-key) provider chain.
// The read path (`latestActiveReceipt`, src/lib/consent/receipts.ts)
// filters only on { user_id, kind, revoked_at IS NULL } and never parses
// the artefact, so a single active `ai_full` row — the master grant that
// satisfies both the Coach and Insights surfaces — is enough to render the
// seeded conversation on the read-only demo with no client-side accept
// (which the read-only tenant would reject). The partial unique index
// consent_receipts_user_id_kind_active_key allows exactly one active row
// per (user, kind); we seed one. artefact is a clearly-labelled demo
// placeholder (any non-empty value satisfies the gate).
console.log("Creating AI-processing consent receipt...");
await client.query(
`INSERT INTO consent_receipts (id, user_id, kind, artefact, signed_at, revoked_at, created_at)
VALUES ($1, $2, 'ai_full', $3, NOW(), NULL, NOW())`,
[
cuid(),
userId,
JSON.stringify({ source: "demo-seed", scope: "ai_full" }),
],
);
// ── Mood Entries ─────────────────────────