diff --git a/CHANGELOG.md b/CHANGELOG.md index 392b93d13..04c9750e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [1.28.26] — 2026-07-11 — Internal restructuring for maintainability + +No user-facing change. Eight of the largest source files were split along +their natural seams into focused modules — mood analytics calculators, the +insight status-invalidation machinery, feature extraction blocks, the doctor +report's types and helpers, the Google Health mapping layer, the coach chat +bubbles and read-aloud controls, the Telegram webhook handlers (the route now +holds only auth and dispatch), and the coach snapshot's cache, series helpers +and largest per-metric blocks. Every move is verbatim with stable import +paths; behavior is pinned by the full test suite. + ## [1.28.25] — 2026-07-11 — Every integration held to the same standard A platform-wide hardening pass: the failure classes found live this week were diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3247dd603..9bb79d675 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.25 + version: 1.28.26 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 22ad0c9e6..d3a680a9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.25", + "version": "1.28.26", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 7b3ad88a5..496d3dd95 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.25"; + /* @sw-version-fallback */ "v1.28.26"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index d6e9f1555..fd9a5b3a8 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -1,64 +1,22 @@ import { timingSafeEqual } from "node:crypto"; -import { prisma } from "@/lib/db"; -import { isP2025 } from "@/lib/prisma-errors"; -import { decrypt } from "@/lib/crypto"; -import { - answerTelegramCallbackQuery, - deleteMessage, - sendTelegramMessage, -} from "@/lib/telegram"; -import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -import { getClientIp } from "@/lib/api-response"; import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { getClientIp } from "@/lib/api-response"; import { annotate, getEvent } from "@/lib/logging/context"; -import { getServerTranslator } from "@/lib/i18n/server-translator"; -import { locales, type Locale } from "@/lib/i18n/config"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { - applyCanonicalSlotWrite, - resolveForcedSlotForWrite, - resolveSlotForWriteByBand, -} from "@/lib/medications/scheduling/slot-upsert"; -import { invalidateUserMedications } from "@/lib/cache/invalidate"; -import { - consumeForIntake, - restoreForIntake, -} from "@/lib/medications/inventory/consumption"; -import { localHmAsUtc } from "@/lib/tz/local-day"; -import { wallClockInTz } from "@/lib/tz/wall-clock"; -import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { - logTelegramMood, - attachTelegramMoodNote, -} from "@/lib/mood/create-from-telegram"; -import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup"; -import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; -import { - isTelegramCapturableType, - logTelegramMeasurement, -} from "@/lib/measurements/create-from-telegram"; + handleCallback, + handleTextMessage, + type TelegramUpdate, +} from "@/lib/telegram-webhook-handlers"; -interface TelegramUpdate { - update_id: number; - message?: { - message_id?: number; - text?: string; - chat?: { id: number | string }; - reply_to_message?: { - message_id?: number; - }; - }; - callback_query?: { - id: string; - data?: string; - from?: { id?: number | string }; - message?: { - message_id?: number; - chat?: { id: number | string }; - }; - }; -} +/** + * Telegram bot webhook. Thin shell over + * `@/lib/telegram-webhook-handlers`: per-source rate limit FIRST, then + * the shared-secret check (constant-time compare), then a bounded JSON + * parse, then dispatch to the callback / free-text handlers. Invalid + * payloads acknowledge with 200 so Telegram does not retry-loop them. + */ function hasValidSecret(request: NextRequest): boolean { const expected = process.env.TELEGRAM_WEBHOOK_SECRET; @@ -74,1179 +32,6 @@ function hasValidSecret(request: NextRequest): boolean { return timingSafeEqual(a, b); } -function toChatId(value: number | string | undefined | null): string | null { - if (value === undefined || value === null) return null; - return String(value); -} - -function escapeHtml(input: string): string { - return input - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">"); -} - -/** - * Resolve the bot locale for an existing Telegram user. - * - * Defaults to "de" when User.locale is null — existing bots were set up in - * German and we don't want to silently flip them. Users who have switched - * the UI to English (User.locale = "en") get an English bot too. - */ -function resolveBotLocale(value: string | null | undefined): Locale { - if (value && (locales as readonly string[]).includes(value)) { - return value as Locale; - } - return "de"; -} - -async function cleanupReminderTracking(medicationId: string): Promise { - try { - await prisma.telegramReminderMessage.deleteMany({ - where: { medicationId }, - }); - } catch { - // Best-effort cleanup - } -} - -// v1.19.0 — the auto-delete window dropped from 1 h to ~30 min and moved to -// the shared `scheduleTelegramAutoDelete` helper so the inbound (free-text) -// and outbound (unanswered prompt) paths agree on the window. -async function scheduleAutoDelete( - userId: string, - chatId: string, - messageIds: number[], -): Promise { - await scheduleTelegramAutoDelete(userId, chatId, messageIds); -} - -async function findTelegramUser(chatId: string) { - return prisma.user.findFirst({ - where: { - telegramEnabled: true, - telegramChatId: chatId, - telegramBotToken: { not: null }, - }, - select: { - id: true, - telegramBotToken: true, - locale: true, - // v1.4.39 W-MED — pulled into the select so the post-write - // compliance-rollup hook can anchor the user-day bucket. - timezone: true, - }, - }); -} - -/** - * v1.16.9 — the slot a Telegram action should converge onto. - * - * The reminder context knows its own slot: the dispatcher records every - * reminder message as a `TelegramReminderMessage` row carrying the - * local `date` + `timeOfDay` of the dose it fired for. When the action - * callback carries the chat/message ids, that row names the EXACT slot - * — preferred over band attribution, which would orphan a confirmation - * tapped hours after the reminder (band closed → ad-hoc) even though - * the user is answering a specific dose prompt. - * - * Returns the canonical band anchor (validated against the schedule's - * real slots), or `null` when no reminder row matches / the instant is - * not a real slot — the caller then falls back to band attribution. - */ -async function resolveReminderSlot(input: { - userId: string; - medicationId: string; - userTz: string; - chatId: string; - messageId: number; -}): Promise { - const reminder = await prisma.telegramReminderMessage.findFirst({ - where: { - medicationId: input.medicationId, - chatId: input.chatId, - messageId: input.messageId, - }, - select: { date: true, timeOfDay: true }, - }); - if (!reminder) return null; - const instant = reminderLocalInstant( - reminder.date, - reminder.timeOfDay, - input.userTz, - ); - if (!instant) return null; - // Validate against the medication's real band anchors so a stale or - // malformed reminder row can never park a dose on a non-slot instant. - return resolveForcedSlotForWrite({ - userId: input.userId, - medicationId: input.medicationId, - userTz: input.userTz, - slotInstant: instant, - }); -} - -/** - * Mint the UTC instant of local `timeOfDay` on the local calendar day - * `date` (YYYY-MM-DD) in `tz`. Probes a reference instant a day either - * side of UTC noon so the result is correct in every zone offset, then - * derives the DST-correct instant via `localHmAsUtc`. - */ -function reminderLocalInstant( - date: string, - timeOfDay: string, - tz: string, -): Date | null { - if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(timeOfDay)) { - return null; - } - const [hour, minute] = timeOfDay.split(":").map(Number); - const base = new Date(`${date}T12:00:00.000Z`); - if (Number.isNaN(base.getTime())) return null; - for (const shiftDays of [0, -1, 1]) { - const ref = new Date(base.getTime() + shiftDays * 24 * 60 * 60 * 1000); - const parts = wallClockInTz(ref, tz); - const localDay = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String( - parts.day, - ).padStart(2, "0")}`; - if (localDay === date) return localHmAsUtc(ref, tz, hour, minute); - } - return null; -} - -async function markMedicationTaken( - userId: string, - medicationId: string, - idempotencyKey: string, - locale: Locale, - userTz: string | null, - reminderRef?: { chatId: string; messageId: number }, -): Promise<{ ok: boolean; message: string; medicationName?: string }> { - const { t } = getServerTranslator(locale); - const medication = await prisma.medication.findFirst({ - where: { id: medicationId, userId, active: true }, - select: { id: true, name: true }, - }); - if (!medication) { - return { ok: false, message: t("telegram.errorMedicationInactive") }; - } - - const existing = await prisma.medicationIntakeEvent.findFirst({ - where: { userId, medicationId: medication.id, idempotencyKey }, - select: { id: true }, - }); - - let scheduledFor: Date | null = null; - if (!existing) { - const takenAt = new Date(); - const tz = userTz || DEFAULT_TIMEZONE; - - // v1.16.9 — converge onto the slot's canonical row instead of bare- - // creating a second row anchored at `now`. The old shape left the - // worker-minted pending REMINDER row open: the slot later auto-missed - // and compliance punished a dose the user explicitly confirmed. - // Prefer the reminder's own slot (the message names the dose it fired - // for); fall back to band attribution on the tap instant. - let canonicalSlot: Date | null = reminderRef - ? await resolveReminderSlot({ - userId, - medicationId: medication.id, - userTz: tz, - chatId: reminderRef.chatId, - messageId: reminderRef.messageId, - }) - : null; - if (canonicalSlot === null) { - const attribution = await resolveSlotForWriteByBand({ - userId, - medicationId: medication.id, - userTz: tz, - takenAt, - }); - canonicalSlot = attribution.slotInstant; - } - - // The intake write and the snooze reset commit atomically — a - // recorded dose must never leave the medication snoozed-out of its - // own reminders, and a snooze reset must never land without the dose - // (the pre-band shape used a batch transaction here; the slot - // convergence dropped it). The canonical slot write accepts the - // transaction client; its P2002 race-recovery cannot continue inside - // an aborted Postgres transaction, so the rare create race rolls the - // whole write back instead of converging in place — Telegram - // redelivers the non-200 update and the retry finds the raced row. - const written = await prisma.$transaction(async (tx) => { - let slotScheduledFor: Date; - let eventId: string; - // v1.16.10 — only a genuine pending→taken transition may consume - // inventory units; a redelivered button press converging onto an - // already-taken slot row (including a pre-stamp row) must not. - let consumedTransition = true; - if (canonicalSlot) { - const applied = await applyCanonicalSlotWrite({ - client: tx, - userId, - medicationId: medication.id, - canonicalSlot, - takenAt, - skipped: false, - isExplicitTaken: true, - isExplicitSkip: false, - idempotencyKey, - createSource: "REMINDER", - attributionSource: "AUTO", - }); - slotScheduledFor = applied.row.scheduledFor; - eventId = applied.row.id; - consumedTransition = applied.consumedTransition; - } else { - // Ad-hoc / PRN — standalone row under the documented contract - // (`scheduledFor = takenAt`). - const created = await tx.medicationIntakeEvent.create({ - data: { - userId, - medicationId: medication.id, - scheduledFor: takenAt, - takenAt, - skipped: false, - source: "REMINDER", - idempotencyKey, - }, - }); - slotScheduledFor = created.scheduledFor; - eventId = created.id; - } - await tx.medication.update({ - where: { id: medication.id }, - data: { snoozedUntil: null }, - }); - return { slotScheduledFor, eventId, consumedTransition }; - }); - scheduledFor = written.slotScheduledFor; - // v1.16.10 — the button confirmed a take; consume inventory units. - // Runs after the intake transaction committed (the dose record must - // never hinge on the stock write); the stamp keeps a Telegram - // redelivery exactly-once and the transition gate keeps a re-press - // on an already-taken slot from decrementing through a pre-stamp - // row. - if (written.consumedTransition) { - await consumeForIntake({ - client: prisma, - userId, - medicationId: medication.id, - eventId: written.eventId, - intakeAt: takenAt, - }); - } - // The confirmation must reflect on the next read (today feed, card - // pill, compliance) rather than wait out the cache TTL. - invalidateUserMedications(userId, { evict: true }); - } - - // v1.4.39 W-MED — refresh the compliance rollup for the - // affected day so the next read after the cache miss reflects the - // new dose. Only fires when the write landed a row. - if (scheduledFor) { - await recomputeMedicationComplianceForEvent({ - userId, - medicationId: medication.id, - scheduledFor, - tz: userTz, - }); - } - - return { - ok: true, - message: existing - ? t("telegram.alreadyRecorded", { name: medication.name }) - : t("telegram.recordedAsTaken", { name: medication.name }), - medicationName: medication.name, - }; -} - -async function handleCallback(update: TelegramUpdate) { - const callback = update.callback_query; - if (!callback) return; - - const chatId = - toChatId(callback.message?.chat?.id) ?? toChatId(callback.from?.id); - if (!chatId) return; - - const user = await findTelegramUser(chatId); - if (!user?.telegramBotToken) return; - const botToken = decrypt(user.telegramBotToken); - const locale = resolveBotLocale(user.locale); - const { t } = getServerTranslator(locale); - - const data = callback.data ?? ""; - const messageId = callback.message?.message_id; - - if (data.startsWith("taken:")) { - const medicationId = data.slice("taken:".length).trim(); - if (!medicationId) { - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.errorInvalidAction"), - ); - return; - } - - const msgId = messageId ?? "na"; - const idempotencyKey = - `telegram:cb:${chatId}:${msgId}:${medicationId}`.slice(0, 128); - - const result = await markMedicationTaken( - user.id, - medicationId, - idempotencyKey, - locale, - user.timezone, - messageId !== undefined ? { chatId, messageId } : undefined, - ); - await answerTelegramCallbackQuery(botToken, callback.id, result.message); - if (messageId) { - await deleteMessage(botToken, chatId, messageId); - } - await cleanupReminderTracking(medicationId); - } else if (data.startsWith("snooze:")) { - // Format: "snooze:{medicationId}:{minutes}" - const parts = data.split(":"); - const medicationId = parts[1]; - const minutes = parseInt(parts[2], 10); - if (!medicationId || !Number.isFinite(minutes)) { - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.errorInvalidAction"), - ); - return; - } - - const medication = await prisma.medication.findFirst({ - where: { id: medicationId, userId: user.id, active: true }, - select: { id: true, name: true }, - }); - if (!medication) { - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.errorMedicationNotFound"), - ); - return; - } - - await prisma.medication.update({ - where: { id: medication.id }, - data: { snoozedUntil: new Date(Date.now() + minutes * 60000) }, - }); - - const duration = - minutes <= 60 - ? t("telegram.snoozeOneHour") - : t("telegram.snoozeThreeHours"); - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.snoozedFor", { name: medication.name, duration }), - ); - if (messageId) { - await deleteMessage(botToken, chatId, messageId); - } - await cleanupReminderTracking(medicationId); - } else if (data.startsWith("skip:")) { - const medicationId = data.slice("skip:".length).trim(); - if (!medicationId) { - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.errorInvalidAction"), - ); - return; - } - - const medication = await prisma.medication.findFirst({ - where: { id: medicationId, userId: user.id, active: true }, - select: { id: true, name: true }, - }); - if (!medication) { - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.errorMedicationNotFound"), - ); - return; - } - - const msgId = messageId ?? "na"; - const idempotencyKey = - `telegram:skip:${chatId}:${msgId}:${medicationId}`.slice(0, 128); - - const existing = await prisma.medicationIntakeEvent.findFirst({ - where: { userId: user.id, medicationId: medication.id, idempotencyKey }, - select: { id: true }, - }); - - let skippedScheduledFor: Date | null = null; - if (!existing) { - const endOfDay = new Date(); - endOfDay.setHours(23, 59, 59, 999); - const skipMoment = new Date(); - const tz = user.timezone || DEFAULT_TIMEZONE; - - // v1.16.9 — a Telegram skip answers a specific dose prompt, so it - // must converge onto the slot's pending row like a take does. The - // old shape inserted a second row anchored at `now` and left the - // pending REMINDER row open to auto-miss — the user's deliberate - // skip recorded as a miss AND an orphan. Prefer the reminder's own - // slot; fall back to the band the skip moment falls in. - let canonicalSlot: Date | null = - messageId !== undefined - ? await resolveReminderSlot({ - userId: user.id, - medicationId: medication.id, - userTz: tz, - chatId, - messageId, - }) - : null; - if (canonicalSlot === null) { - const attribution = await resolveSlotForWriteByBand({ - userId: user.id, - medicationId: medication.id, - userTz: tz, - takenAt: skipMoment, - }); - canonicalSlot = attribution.slotInstant; - } - - // Atomic like the take path: the skip row and the rest-of-day - // snooze commit together, so a failure can never leave the user - // re-reminded for a recorded skip or silenced without one. Same - // P2002 trade-off as the take path — the rare create race rolls - // back and Telegram's redelivery converges on the raced row. - const writtenSkip = await prisma.$transaction(async (tx) => { - let slotScheduledFor: Date; - let eventId: string; - if (canonicalSlot) { - const applied = await applyCanonicalSlotWrite({ - client: tx, - userId: user.id, - medicationId: medication.id, - canonicalSlot, - takenAt: null, - skipped: true, - isExplicitTaken: false, - isExplicitSkip: true, - idempotencyKey, - createSource: "REMINDER", - }); - slotScheduledFor = applied.row.scheduledFor; - eventId = applied.row.id; - } else { - const created = await tx.medicationIntakeEvent.create({ - data: { - userId: user.id, - medicationId: medication.id, - scheduledFor: skipMoment, - takenAt: null, - skipped: true, - source: "REMINDER", - idempotencyKey, - }, - }); - slotScheduledFor = created.scheduledFor; - eventId = created.id; - } - await tx.medication.update({ - where: { id: medication.id }, - data: { snoozedUntil: endOfDay }, - }); - return { slotScheduledFor, eventId }; - }); - skippedScheduledFor = writtenSkip.slotScheduledFor; - // v1.16.10 — an explicit skip can downgrade a previously-taken - // slot row (last-write-wins); refund whatever its consumption - // stamp recorded. No-op for a never-consumed row. - await restoreForIntake({ - client: prisma, - userId: user.id, - eventId: writtenSkip.eventId, - }); - invalidateUserMedications(user.id, { evict: true }); - } - - // v1.4.39 W-MED — refresh the compliance rollup for the skipped - // event's user-day so the next read sees the (scheduled, skipped) - // counts incremented. - if (skippedScheduledFor) { - await recomputeMedicationComplianceForEvent({ - userId: user.id, - medicationId: medication.id, - scheduledFor: skippedScheduledFor, - tz: user.timezone, - }); - } - - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.skipped", { name: medication.name }), - ); - if (messageId) { - await deleteMessage(botToken, chatId, messageId); - } - await cleanupReminderTracking(medicationId); - } else if (data.startsWith("ack:")) { - const medicationId = data.slice("ack:".length).trim(); - if (!medicationId) { - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.errorInvalidAction"), - ); - return; - } - - const medication = await prisma.medication.findFirst({ - where: { id: medicationId, userId: user.id }, - select: { id: true, name: true }, - }); - - await answerTelegramCallbackQuery( - botToken, - callback.id, - medication - ? t("telegram.confirmed", { name: medication.name }) - : t("telegram.genericConfirmed"), - ); - if (messageId) { - await deleteMessage(botToken, chatId, messageId); - } - await cleanupReminderTracking(medicationId); - } else if (data.startsWith("add:")) { - // Format: "add:{medicationId}" or "add:{medicationId}:umid:{userMsgId}" - const withoutPrefix = data.slice("add:".length); - const umidIdx = withoutPrefix.indexOf(":umid:"); - const medicationId = - umidIdx >= 0 ? withoutPrefix.slice(0, umidIdx) : withoutPrefix.trim(); - if (!medicationId) { - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.errorInvalidAction"), - ); - return; - } - - const msgId = messageId ?? "na"; - const idempotencyKey = - `telegram:add:${chatId}:${msgId}:${medicationId}`.slice(0, 128); - - const result = await markMedicationTaken( - user.id, - medicationId, - idempotencyKey, - locale, - user.timezone, - ); - await answerTelegramCallbackQuery(botToken, callback.id, result.message); - if (messageId) { - await deleteMessage(botToken, chatId, messageId); - } - // Also delete the user's /add command message if encoded - const userMsgMatch = data.match(/:umid:(\d+)$/); - if (userMsgMatch) { - const userMsgId = parseInt(userMsgMatch[1], 10); - if (Number.isFinite(userMsgId)) { - await deleteMessage(botToken, chatId, userMsgId).catch(() => {}); - } - } - } else if (data.startsWith("cancel_add")) { - // Delete the bot's selection message - if (messageId) { - await deleteMessage(botToken, chatId, messageId); - } - // Delete the user's original /add command message if encoded - const userMsgMatch = data.match(/:umid:(\d+)$/); - if (userMsgMatch) { - const userMsgId = parseInt(userMsgMatch[1], 10); - if (Number.isFinite(userMsgId)) { - await deleteMessage(botToken, chatId, userMsgId).catch(() => {}); - } - } - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.cancelled"), - ); - } else if (data.startsWith("mood:")) { - // v1.19.0 — log a mood entry 1–5 from a MOOD_REMINDER prompt tap. - // The score is the ONLY accepted value; the userId is resolved from - // the linked chat above, never from the payload. Confirmation is a - // calm callback toast — no chat echo (restraint). - const score = parseInt(data.slice("mood:".length).trim(), 10); - if (!Number.isInteger(score) || score < 1 || score > 5) { - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.errorInvalidAction"), - ); - return; - } - - const msgId = messageId ?? "na"; - // Stable per-tap dedup id (Telegram redelivers non-200 updates). The - // mood-create helper upserts on `(userId, source, externalId)`. - const externalId = `telegram:mood:${chatId}:${msgId}:${score}`.slice( - 0, - 120, - ); - - const moodResult = await logTelegramMood({ - userId: user.id, - score, - tz: user.timezone, - externalId, - }); - - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.moodLogged", { score: String(score) }), - ); - // Delete the prompt immediately on a tap; the unanswered-prompt - // scheduled deletion (written at send time) is then a harmless no-op. - if (messageId) { - await deleteMessage(botToken, chatId, messageId); - } - - // Offer an optional note via force_reply. The reply (when it arrives) - // is correlated back to this mood entry through TelegramPromptContext. - const notePrompt = await sendTelegramMessage( - botToken, - chatId, - t("telegram.moodNotePrompt"), - { - replyMarkup: { - force_reply: true, - input_field_placeholder: t("telegram.buttonMoodNote"), - }, - }, - ); - if (notePrompt.ok && notePrompt.messageId) { - try { - await prisma.telegramPromptContext.create({ - data: { - userId: user.id, - chatId, - promptMsgId: notePrompt.messageId, - kind: "mood_note", - refId: moodResult.moodEntryId, - // Force-reply context lifespan: the same ~30-min window as the - // deletion sweep. A reply after this is treated as expired. - expiresAt: new Date(Date.now() + 30 * 60 * 1000), - }, - }); - } catch { - // Best-effort — without the context row the note simply can't bind. - } - // The note prompt itself self-cleans on the ~30-min sweep whether or - // not the user replies. - await scheduleAutoDelete(user.id, chatId, [notePrompt.messageId]); - } - } else if (data.startsWith("mood_later")) { - // v1.19.0 — restrained "remind me later" for the mood nudge. Clears - // today's dispatch ledger so the next cron tick (inside the user's - // reminder-hour window) re-fires the prompt, mirroring how the - // medication snooze lets the tick re-remind. The prompt is deleted now. - const tz = user.timezone || DEFAULT_TIMEZONE; - const parts = wallClockInTz(new Date(), tz); - const localDate = `${parts.year.toString().padStart(4, "0")}-${parts.month - .toString() - .padStart(2, "0")}-${parts.day.toString().padStart(2, "0")}`; - try { - await prisma.moodReminderDispatch.deleteMany({ - where: { userId: user.id, date: localDate }, - }); - } catch { - // Best-effort — a failure just means the day's nudge isn't re-armed. - } - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.remindLater", { duration: t("telegram.snoozeOneHour") }), - ); - if (messageId) { - await deleteMessage(botToken, chatId, messageId); - } - } else if (data.startsWith("measure_done:")) { - // v1.19.0 — mark a Vorsorge/measurement reminder done; satisfy the - // cadence via the shared primitive. - const reminderId = data.slice("measure_done:".length).trim(); - const reminder = await prisma.measurementReminder.findFirst({ - where: { id: reminderId, userId: user.id, deletedAt: null }, - select: { - id: true, - // v1.19.2 — the target metric; drives the optional numeric capture. - measurementType: true, - intervalDays: true, - rrule: true, - anchorDate: true, - notifyHour: true, - lastSatisfiedAt: true, - createdAt: true, - }, - }); - if (!reminder) { - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.measurementNotFound"), - ); - return; - } - const tz = user.timezone || DEFAULT_TIMEZONE; - await satisfyReminder(prisma, reminder, tz, new Date()); - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.measurementDone"), - ); - if (messageId) { - await deleteMessage(botToken, chatId, messageId); - } - - // v1.19.2 — optional numeric value capture. When the reminder names a - // single-value metric (BP and free-text Vorsorge are excluded), offer a - // force-reply prompt; a numeric reply is captured as a Measurement - // (source=TELEGRAM) in `handleTextMessage`. The reply binds to THIS - // reminder + user through a TelegramPromptContext keyed on the prompt - // message id, the SAME strict chat/message binding the mood-note path - // uses — the userId is never read from the inbound payload. Optional and - // self-cleaning: ignoring the prompt leaves the cadence already - // satisfied, and the prompt is swept on the ~30-min auto-delete. - if (isTelegramCapturableType(reminder.measurementType)) { - const valuePrompt = await sendTelegramMessage( - botToken, - chatId, - t("telegram.measureValuePrompt"), - { - replyMarkup: { - force_reply: true, - input_field_placeholder: t("telegram.buttonMeasureValue"), - }, - }, - ); - if (valuePrompt.ok && valuePrompt.messageId) { - try { - await prisma.telegramPromptContext.create({ - data: { - userId: user.id, - chatId, - promptMsgId: valuePrompt.messageId, - kind: "measure_value", - refId: reminder.id, - // Same ~30-min window as the deletion sweep; a later reply is - // treated as expired. - expiresAt: new Date(Date.now() + 30 * 60 * 1000), - }, - }); - } catch { - // Best-effort — without the context row the value simply can't bind. - } - await scheduleAutoDelete(user.id, chatId, [valuePrompt.messageId]); - } - } - } else if (data.startsWith("measure_later:")) { - // v1.19.0 — postpone a measurement reminder by N minutes (one-shot - // nudge). Pushes `nextDueAt` forward; the cron re-fires once due AND in - // the user's notify-hour window. Mirrors the medication snooze. - const parts = data.split(":"); - const reminderId = parts[1]; - const minutes = parseInt(parts[2] ?? "180", 10); - if (!reminderId || !Number.isFinite(minutes)) { - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.errorInvalidAction"), - ); - return; - } - const reminder = await prisma.measurementReminder.findFirst({ - where: { id: reminderId, userId: user.id, deletedAt: null }, - select: { id: true }, - }); - if (!reminder) { - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.measurementNotFound"), - ); - return; - } - await prisma.measurementReminder.update({ - where: { id: reminder.id }, - data: { nextDueAt: new Date(Date.now() + minutes * 60000) }, - }); - const duration = - minutes <= 60 - ? t("telegram.snoozeOneHour") - : t("telegram.snoozeThreeHours"); - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.remindLater", { duration }), - ); - if (messageId) { - await deleteMessage(botToken, chatId, messageId); - } - } else { - await answerTelegramCallbackQuery( - botToken, - callback.id, - t("telegram.errorUnknownAction"), - ); - } -} - -async function handleTextMessage(update: TelegramUpdate) { - const message = update.message; - const text = message?.text?.trim(); - const chatId = toChatId(message?.chat?.id); - if (!text || !chatId) return; - - const user = await findTelegramUser(chatId); - if (!user?.telegramBotToken) return; - const botToken = decrypt(user.telegramBotToken); - const locale = resolveBotLocale(user.locale); - const { t } = getServerTranslator(locale); - - // v1.19.0 — force-reply note capture. When this message replies to a - // tracked mood-note prompt, attach the text to the just-logged mood entry - // and clean up both messages. The context row is keyed on - // `(chatId, promptMsgId)` and scoped to THIS user, so a reply can only - // ever attach to that user's own entry. - const replyToId = message?.reply_to_message?.message_id; - if (replyToId !== undefined) { - const context = await prisma.telegramPromptContext.findUnique({ - where: { - chatId_promptMsgId: { chatId, promptMsgId: replyToId }, - }, - select: { - id: true, - userId: true, - kind: true, - refId: true, - expiresAt: true, - }, - }); - if ( - context && - context.userId === user.id && - (context.kind === "mood_note" || context.kind === "measure_value") - ) { - // The delete IS the replay guard: consuming the context row is what - // stops a redelivered reply from being applied to the entry twice. Make - // it authoritative — if the delete fails for any reason OTHER than the - // row already being gone (P2025), do NOT apply the reply; abort and let - // Telegram redeliver against a still-present row. P2025 means the row - // was already consumed, which is the guard succeeding, so proceed. - try { - await prisma.telegramPromptContext.delete({ - where: { id: context.id }, - }); - } catch (err) { - if (!isP2025(err)) return; - } - const userMsgId = message?.message_id; - if (context.expiresAt.getTime() < Date.now()) { - const resp = await sendTelegramMessage( - botToken, - chatId, - context.kind === "measure_value" - ? t("telegram.measureValueExpired") - : t("telegram.moodNoteExpired"), - ); - const toDelete = [userMsgId, replyToId, resp.messageId].filter( - (id): id is number => id != null, - ); - if (toDelete.length > 0) { - await scheduleAutoDelete(user.id, chatId, toDelete); - } - return; - } - - if (context.kind === "measure_value") { - // v1.19.2 — numeric value capture for a measurement reminder. The - // reminder named on the context row carries the expected metric; the - // userId is the chat-resolved one (never the payload). A non-numeric - // / out-of-range reply gets a calm hint, not a write. The reply, the - // prompt, and the confirmation all self-clean on the ~30-min sweep. - const reminder = await prisma.measurementReminder.findFirst({ - where: { id: context.refId, userId: user.id, deletedAt: null }, - select: { measurementType: true }, - }); - let confirmation: string; - if (!isTelegramCapturableType(reminder?.measurementType)) { - confirmation = t("telegram.measurementNotFound"); - } else { - const externalId = `telegram:measure:${chatId}:${replyToId}`.slice( - 0, - 120, - ); - const result = await logTelegramMeasurement({ - userId: user.id, - type: reminder.measurementType, - rawText: text, - tz: user.timezone, - externalId, - }); - confirmation = - result.status === "ok" - ? t("telegram.measureValueSaved") - : result.status === "out_of_range" - ? t("telegram.measureValueOutOfRange") - : t("telegram.measureValueInvalid"); - } - const resp = await sendTelegramMessage(botToken, chatId, confirmation); - const toDelete = [userMsgId, replyToId, resp.messageId].filter( - (id): id is number => id != null, - ); - if (toDelete.length > 0) { - await scheduleAutoDelete(user.id, chatId, toDelete); - } - return; - } - - const attached = await attachTelegramMoodNote({ - userId: user.id, - moodEntryId: context.refId, - note: text, - }); - const resp = await sendTelegramMessage( - botToken, - chatId, - attached - ? t("telegram.moodNoteSaved") - : t("telegram.errorInvalidAction"), - ); - // Self-clean the note, the prompt, and the confirmation in ~30 min. - const toDelete = [userMsgId, replyToId, resp.messageId].filter( - (id): id is number => id != null, - ); - if (toDelete.length > 0) { - await scheduleAutoDelete(user.id, chatId, toDelete); - } - return; - } - } - - // "Help" / start: accept English + German keyword aliases independent of locale - if ( - /^\/help\b/i.test(text) || - /^\/start\b/i.test(text) || - /^hilfe$/i.test(text) || - /^help$/i.test(text) - ) { - const userMsgId = message?.message_id; - const resp = await sendTelegramMessage( - botToken, - chatId, - `${t("telegram.helpHeader")}\n\n${t("telegram.helpBody")}`, - ); - const toDelete = [userMsgId, resp.messageId].filter( - (id): id is number => id != null, - ); - if (toDelete.length > 0) { - await scheduleAutoDelete(user.id, chatId, toDelete); - } - return; - } - - if (/^\/add\b/i.test(text)) { - const userMsgId = message?.message_id; - const meds = await prisma.medication.findMany({ - where: { userId: user.id, active: true }, - select: { id: true, name: true, dose: true }, - orderBy: { name: "asc" }, - }); - - if (meds.length === 0) { - const resp = await sendTelegramMessage( - botToken, - chatId, - t("telegram.noActiveMedications"), - ); - const toDelete = [userMsgId, resp.messageId].filter( - (id): id is number => id != null, - ); - if (toDelete.length > 0) { - await scheduleAutoDelete(user.id, chatId, toDelete); - } - return; - } - - if (meds.length === 1) { - const idempotencyKey = - `telegram:add:${update.update_id}:${meds[0].id}`.slice(0, 128); - const result = await markMedicationTaken( - user.id, - meds[0].id, - idempotencyKey, - locale, - user.timezone, - ); - const resp = await sendTelegramMessage( - botToken, - chatId, - result.ok - ? `${escapeHtml(result.message)}` - : `${escapeHtml(result.message)}`, - ); - const toDelete = [userMsgId, resp.messageId].filter( - (id): id is number => id != null, - ); - if (toDelete.length > 0) { - await scheduleAutoDelete(user.id, chatId, toDelete); - } - return; - } - - // Encode user's message ID in callback data so cancel/add can delete it - const umidSuffix = userMsgId ? `:umid:${userMsgId}` : ""; - const keyboard = { - inline_keyboard: [ - ...meds.map((med) => [ - { - text: `${med.name} (${med.dose})`, - callback_data: `add:${med.id}${umidSuffix}`, - }, - ]), - [ - { - text: t("telegram.cancelButton"), - callback_data: `cancel_add${umidSuffix}`, - }, - ], - ], - }; - const resp = await sendTelegramMessage( - botToken, - chatId, - t("telegram.whichMedication"), - { parseMode: "HTML", replyMarkup: keyboard }, - ); - // Fallback auto-delete if user never interacts with the selection - const toDelete = [userMsgId, resp.messageId].filter( - (id): id is number => id != null, - ); - if (toDelete.length > 0) { - await scheduleAutoDelete(user.id, chatId, toDelete); - } - return; - } - - // Greeting responses (kept locale-independent) - const greetings = ["hi", "hallo", "hey", "moin", "hello"]; - const lowerText = text.toLowerCase(); - const matchedGreeting = greetings.find((g) => lowerText === g); - if (matchedGreeting) { - const userMsgId = message?.message_id; - const reply = text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); - const resp = await sendTelegramMessage(botToken, chatId, `${reply}! 👋`); - const toDelete = [userMsgId, resp.messageId].filter( - (id): id is number => id != null, - ); - if (toDelete.length > 0) { - await scheduleAutoDelete(user.id, chatId, toDelete); - } - return; - } - - // Accept both "genommen " (DE) and "taken " (EN) regardless of - // the user's UI locale — keeps backwards compatibility. - const intakeKeyword = /^(?:genommen|taken)\b/i; - if (!intakeKeyword.test(text)) return; - - const userMsgId = message?.message_id; - const nameInput = text.replace(intakeKeyword, "").trim(); - - let medicationId: string | null = null; - if (nameInput) { - const med = await prisma.medication.findFirst({ - where: { - userId: user.id, - active: true, - name: { equals: nameInput, mode: "insensitive" }, - }, - select: { id: true }, - }); - medicationId = med?.id ?? null; - } else { - const meds = await prisma.medication.findMany({ - where: { userId: user.id, active: true }, - select: { id: true, name: true }, - orderBy: { name: "asc" }, - take: 2, - }); - if (meds.length === 1) { - medicationId = meds[0].id; - } else { - const resp = await sendTelegramMessage( - botToken, - chatId, - t("telegram.askMedicationName"), - ); - const toDelete = [userMsgId, resp.messageId].filter( - (id): id is number => id != null, - ); - if (toDelete.length > 0) { - await scheduleAutoDelete(user.id, chatId, toDelete); - } - return; - } - } - - if (!medicationId) { - const resp = await sendTelegramMessage( - botToken, - chatId, - t("telegram.medicationNotFoundExact"), - ); - const toDelete = [userMsgId, resp.messageId].filter( - (id): id is number => id != null, - ); - if (toDelete.length > 0) { - await scheduleAutoDelete(user.id, chatId, toDelete); - } - return; - } - - const idempotencyKey = - `telegram:text:${update.update_id}:${medicationId}`.slice(0, 128); - const result = await markMedicationTaken( - user.id, - medicationId, - idempotencyKey, - locale, - user.timezone, - ); - const resp = await sendTelegramMessage( - botToken, - chatId, - result.ok - ? `✅ ${escapeHtml(result.message)}` - : `⚠️ ${escapeHtml(result.message)}`, - ); - const toDelete = [userMsgId, resp.messageId].filter( - (id): id is number => id != null, - ); - if (toDelete.length > 0) { - await scheduleAutoDelete(user.id, chatId, toDelete); - } -} - export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "telegram.webhook" } }); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 1926f9822..dba39e1ee 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -596,7 +596,7 @@ describe("", () => { const src = readFileSync( join( process.cwd(), - "src/components/insights/coach-panel/message-thread.tsx", + "src/components/insights/coach-panel/chat-bubble.tsx", ), "utf8", ); diff --git a/src/components/insights/coach-panel/__tests__/read-aloud-voice.test.ts b/src/components/insights/coach-panel/__tests__/read-aloud-voice.test.ts index 210083104..58f65d18c 100644 --- a/src/components/insights/coach-panel/__tests__/read-aloud-voice.test.ts +++ b/src/components/insights/coach-panel/__tests__/read-aloud-voice.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { pickSpeechVoice, scoreSpeechVoice } from "../message-thread"; +import { pickSpeechVoice, scoreSpeechVoice } from "../read-aloud"; type V = { name: string; lang: string; localService?: boolean }; diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx new file mode 100644 index 000000000..49f3d7dd7 --- /dev/null +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -0,0 +1,1025 @@ +"use client"; + +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import Link from "next/link"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + BookmarkPlus, + Bot, + Check, + ChevronRight, + Clock, + Copy, + Loader2, + RotateCcw, + Sparkles, + ThumbsDown, + ThumbsUp, + User, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { useAuth } from "@/hooks/use-auth"; +import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me"; +import { + parseChartTokens, + stripChartTokens, + tokenToMetric, + type ChartToken, +} from "@/lib/insights/chart-tokens"; +import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; +import { ProseBlocks } from "@/components/insights/prose-blocks"; + +import { SourceChips } from "./source-chips"; +import { ReminderSuggestionCard } from "./reminder-suggestion-card"; +import { SuggestedActionCard } from "./suggested-action-card"; +import { StreamedProse } from "./streamed-prose"; +import { MessageTokenFooter } from "./message-token-footer"; +import { + COACH_ICON_BUTTON, + ReadAloudButton, + useClipboardSupported, +} from "./read-aloud"; +import type { CoachProvenanceMetric } from "@/lib/ai/coach/types"; + +/** + * Per-message bubble renderer for the Coach thread, split out of + * `message-thread.tsx` (v1.28.26 file-size split — pure code motion, no + * behaviour change). Owns the user + assistant `ChatBubble` (evidence + * disclosure, accompanying charts, action row), the per-message action + * buttons (copy / feedback / try-again / timestamp / remember), the + * typing indicator, the chart-token grounding selector, and the + * error-code → i18n resolver. `message-thread.tsx` re-exports the + * previously public names so external call sites are unchanged. + */ + +/** + * v1.4.25 W5 — map server-emitted error codes to specific Coach i18n + * keys. The chat route distinguishes the daily user-quota + * (`coach.budget.exceeded`, returned as a JSON 429) from the provider + * rate-limit (`coach.provider.rate_limited`, streamed as an SSE error + * frame). Both used to surface as the generic provider-unavailable + * copy; we now route each to its dedicated translation so the user + * understands whether the limit is on their side (reset at UTC + * midnight) or transient on the provider side (retry in ~5 min). + * + * Exported so the resolver can be pinned by unit tests without + * standing up the whole thread renderer. + */ +export function errorCodeToI18nKey(code: string): string { + switch (code) { + case "coach.budget.exceeded": + return "insights.coach.dailyLimitBody"; + case "coach.provider.rate_limited": + return "insights.coach.providerRateLimitBody"; + case "coach.network": + // v1.4.43 QoL (M6) — a dropped network is the user's local + // problem and needs a different next action ("come back online") + // than a provider failure ("try again in a moment"). Split out + // so the user sees the actionable copy in the offline branch. + return "insights.coach.errorNetwork"; + case "coach.provider.credential_expired": + // v1.11.0 W1 — the user's primary AI provider credential is dead + // (auth-class failure). The next action is "reconnect", not "try + // again later", so it carries its own copy pointing at Settings. + return "insights.coach.errorCredentialExpired"; + case "coach.provider.none": + // v1.18.6 — no AI provider is configured anywhere, so "try again + // in a moment" is the wrong instruction (it will 422 forever). Send + // the user to guided setup with a one-line explainer + a Settings + // link rendered alongside the bubble copy. + return "insights.coach.errorNoProvider"; + case "coach.provider.unavailable": + case "coach.provider.empty": + case "coach.stream": + return "insights.coach.errorProvider"; + default: + // Forward-compat: try `insights.coach.` for codes that + // ship their own translation (e.g. legacy `errorProvider`). + return `insights.coach.${code}`; + } +} + +/** + * v1.22 (W5) — Coach accompanying charts, Phase 1: the renderer half. + * + * The chart-token mechanism (`chart-tokens.ts`) was prepared for the + * Insights prose but its render path was never wired for the Coach — + * `stripChartTokens` cleaned tokens out of the prose, but `parseChartTokens` + * had no caller, so a `metric:` token the model emitted rendered no + * chart. This map activates the render half for the Coach. + * + * Each entry pairs an allowlisted chart token with the Coach provenance + * TOPIC that the snapshot stamps when it actually drew on that metric. A + * chart only renders when its topic is present in `metricSource.metrics`, so + * the series is grounded twice over: the closed allowlist drops a + * hallucinated token, and the provenance intersect drops a metric the turn + * never saw (and which therefore has no data). The chart itself self-fetches + * the user's real series from `/api/measurements` — the model never emits a + * data point. + * + * Only MeasurementType-backed tokens that render through the generic, + * self-fetching `` are listed. The synthetic `metric:MOOD` + * token (served by a separate ``) and the allowlist's reserved + * score classes are intentionally omitted from Phase 1. + * + * The prompt clause that tells the model it MAY emit one such token is a + * separate concern (the narrative/prompt workstream); this is render-only, + * provider-agnostic (it reads the plain inline token, so it works for every + * provider including the codex inline-text path), and a graceful no-op when + * no grounded token is present. + */ +const CHART_TOKEN_PROVENANCE: Partial< + Record +> = { + "metric:WEIGHT": "weight", + "metric:BLOOD_PRESSURE_SYS": "bp", + "metric:BLOOD_PRESSURE_DIA": "bp", + "metric:PULSE": "pulse", + "metric:BODY_FAT": "body_fat", + "metric:SLEEP_DURATION": "sleep", + "metric:ACTIVITY_STEPS": "steps", + "metric:BLOOD_GLUCOSE": "glucose", + "metric:TOTAL_BODY_WATER": "total_body_water", + "metric:BONE_MASS": "bone_mass", + "metric:OXYGEN_SATURATION": "spo2", + "metric:HEART_RATE_VARIABILITY": "hrv", + "metric:RESTING_HEART_RATE": "resting_hr", + "metric:ACTIVE_ENERGY_BURNED": "active_energy", + "metric:FLIGHTS_CLIMBED": "flights", + "metric:WALKING_RUNNING_DISTANCE": "distance", + "metric:VO2_MAX": "vo2_max", + "metric:BODY_TEMPERATURE": "body_temp", + "metric:FAT_FREE_MASS": "fat_free_mass", + "metric:FAT_MASS": "fat_mass", + "metric:MUSCLE_MASS": "muscle_mass", + "metric:LEAN_BODY_MASS": "lean_body_mass", + "metric:BODY_MASS_INDEX": "bmi", + "metric:VISCERAL_FAT": "visceral_fat", + "metric:SKIN_TEMPERATURE": "skin_temp", + "metric:RESPIRATORY_RATE": "respiratory_rate", + "metric:PULSE_WAVE_VELOCITY": "pulse_wave_velocity", + "metric:VASCULAR_AGE": "vascular_age", + "metric:WALKING_HEART_RATE_AVERAGE": "walking_hr", + "metric:WALKING_ASYMMETRY": "walking_asymmetry", + "metric:WALKING_DOUBLE_SUPPORT": "walking_double_support", + "metric:WALKING_STEP_LENGTH": "walking_step_length", + "metric:WALKING_SPEED": "walking_speed", + "metric:AUDIO_EXPOSURE_ENV": "audio_env", + "metric:AUDIO_EXPOSURE_HEADPHONE": "audio_headphone", + "metric:AUDIO_EXPOSURE_EVENT": "audio_event", + "metric:TIME_IN_DAYLIGHT": "daylight", +}; + +/** Max charts rendered under a single Coach turn (keeps the reply scannable). */ +const MAX_COACH_CHARTS = 2; + +/** + * Pure selection of the chart tokens to render under an assistant turn: + * allowlist-parsed, intersected with the turn's grounded provenance topics, + * de-duplicated by metric, and capped. Exported for unit tests so the + * grounding contract is pinned without standing up the chart component. + */ +export function selectCoachChartTokens( + content: string, + metrics: readonly CoachProvenanceMetric[] | undefined, +): ChartToken[] { + const grounded = new Set(metrics ?? []); + const out: ChartToken[] = []; + const seen = new Set(); + for (const token of parseChartTokens(content)) { + const topic = CHART_TOKEN_PROVENANCE[token]; + if (!topic || !grounded.has(topic)) continue; + const metric = tokenToMetric(token); + if (seen.has(metric)) continue; + seen.add(metric); + out.push(token); + if (out.length >= MAX_COACH_CHARTS) break; + } + return out; +} + +/** + * Chart tokens whose canonical measurement-type label has no dedicated + * `measurements.type*` key — route them to the closest existing key so the + * chart header reads cleanly rather than echoing the raw enum. + */ +const CHART_TITLE_KEY_OVERRIDE: Record = { + BLOOD_PRESSURE_SYS: "measurements.typeBloodPressure", + BLOOD_PRESSURE_DIA: "measurements.typeBloodPressure", +}; + +/** Localised chart header for a MeasurementType, mirroring the snapshot's + * `measurements.type` convention with a readable fallback. */ +function coachChartTitle( + metric: string, + t: (key: string, vars?: Record) => string, +): string { + const override = CHART_TITLE_KEY_OVERRIDE[metric]; + if (override) { + const resolved = t(override); + if (resolved !== override) return resolved; + } + const camel = metric + .toLowerCase() + .split("_") + .map((part, i) => + i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1), + ) + .join(""); + const key = `measurements.type${camel.charAt(0).toUpperCase()}${camel.slice(1)}`; + const resolved = t(key); + return resolved === key ? metric.replace(/_/g, " ").toLowerCase() : resolved; +} + +/** + * v1.22 — copy a message to the clipboard. Assistant prose is copied through + * `stripChartTokens` (the same text the bubble shows) so inline chart tokens + * are never pasted; user text is verbatim. A brief check-mark + toast confirm + * the copy. Hidden when the Clipboard API is unavailable (insecure context). + */ +function CopyMessageButton({ + content, + strip, +}: { + content: string; + strip: boolean; +}) { + const { t } = useTranslations(); + const supported = useClipboardSupported(); + const [copied, setCopied] = useState(false); + const handle = useCallback(async () => { + const text = strip ? stripChartTokens(content) : content; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + toast.error(t("insights.coach.copyMessageError")); + } + }, [content, strip, t]); + // Absent on insecure-context (plain-HTTP) self-hosts where the Clipboard API + // is undefined — render nothing rather than error-toast on tap. + if (!supported) return null; + const label = t("insights.coach.copyMessage"); + return ( + + ); +} + +/** + * v1.22 — "Try again": re-run the user turn that produced this assistant + * reply so the user can regenerate an unsatisfying answer. The thread hands + * down the preceding user message; the surface resubmits it as a fresh turn. + */ +function TryAgainButton({ onRegenerate }: { onRegenerate: () => void }) { + const { t } = useTranslations(); + const label = t("insights.coach.regenerate"); + return ( + + ); +} + +/** + * v1.22 (W5) — hover/tap timestamp for a persisted message bubble. A small, + * calm clock affordance; the locale-aware date + time surfaces in a tooltip + * on hover or keyboard focus (desktop) and on tap (mobile, toggled state). + * Pure CSS + one boolean — no portal, no Radix dependency, SSR-safe. + */ +function BubbleTimestamp({ + iso, + align = "start", +}: { + iso: string; + align?: "start" | "end"; +}) { + const { t } = useTranslations(); + const formatters = useFormatters(); + const [open, setOpen] = useState(false); + const label = formatters.dateTime(iso); + return ( + + + + {label} + + + ); +} + +interface ChatBubbleProps { + role: "user" | "assistant"; + content: string; + metricSource?: import("@/lib/ai/coach/types").CoachProvenance | null; + /** + * v1.18.1 (Workstream C) — live cadence suggestion from the streaming + * hook. Persisted messages carry it on `metricSource.suggestion` + * instead; the bubble falls back to that so the card survives reload. + */ + suggestion?: import("@/lib/ai/coach/types").CoachSuggestion | null; + /** + * v1.22 (W7/W6) — live generalised confirm-card action from the streaming + * hook. Persisted messages carry it on `metricSource.suggestedAction`; the + * bubble falls back to that so the card survives reload. + */ + suggestedAction?: + import("@/lib/ai/coach/suggest-action").CoachSuggestedAction | null; + providerType?: string | null; + inProgress?: boolean; + errorCode?: string | null; + /** + * v1.4.23 H7 — present only on persisted assistant messages. + * Streaming bubbles (no message id yet) skip the thumbs row so the + * user can't rate before the message lands on disk. + */ + messageId?: string; + /** + * v1.18.9 — true for the live streaming assistant turn. Drives the + * word-by-word prose fade (``); persisted bubbles render + * settled plain text. + */ + streaming?: boolean; + /** + * v1.18.9 — per-turn token usage for the just-finished streaming bubble + * (from `done.usage`). Persisted bubbles read `tokensUsed` / `model` + * instead. + */ + usage?: import("./use-coach").CoachUsage | null; + /** + * v1.18.9 — persisted per-message token count + model, for the quiet + * token footer on reload. Null on user turns, refusals, and older rows. + */ + tokensUsed?: number | null; + model?: string | null; + /** + * v1.22 (W5) — ISO creation timestamp for the persisted bubble's + * hover/tap timestamp tooltip. Absent on optimistic + streaming bubbles + * (no persisted time yet), so those render no timestamp. + */ + createdAt?: string; + /** + * v1.22 — bound "Try again" callback for a settled assistant turn (the + * thread closes over the preceding user message). Absent on user / streaming + * / refusal turns and when the surface supplies no regenerate handler. + */ + onRegenerate?: () => void; +} + +export function ChatBubble({ + role, + content, + metricSource, + suggestion, + suggestedAction, + providerType, + inProgress, + errorCode, + messageId, + streaming, + usage, + tokensUsed, + model, + createdAt, + onRegenerate, +}: ChatBubbleProps) { + const { t } = useTranslations(); + const { user } = useAuth(); + // v1.4.27 B7 / L3 — pair the evidence `
` and its disclosed + // list explicitly so screen-readers announce the panel relationship. + const evidencePanelId = useId(); + // v1.4.27 MB3 / CF-32 — track the disclosure state in React so the + // summary can carry an accurate `aria-expanded`. Native `
` + // reflects its open state via the `open` attribute, but that does + // not surface as `aria-expanded` on the summary by default; screen + // readers still need the explicit attribute to announce the panel + // as expanded vs collapsed. + const [evidenceOpen, setEvidenceOpen] = useState(false); + if (role === "user") { + // v1.5.5 — pull the user's self-hosted avatar so the user + // bubble matches the Coach avatar in size and visual weight. + // Falls back to initials when the user has not uploaded an + // avatar. Replaces the Gravatar leak (v1.4.22 B3). + const avatarUrl = user?.avatarUrl ?? null; + const initials = user?.username + ? user.username.slice(0, 2).toUpperCase() + : null; + return ( +
+
+
+ {/* v1.22 (W5) — render real paragraph blocks so a multi-line + message reads as paragraphs, not one run-on block. User text + is verbatim: no chart-token strip, no Learn linkify. */} + +
+ {/* v1.22 — per-message action row: Copy, then the timestamp + trailing. Muted until the bubble is hovered / focused on pointer + devices; always visible on touch (no hover to reveal it). */} +
+ + {createdAt && } +
+ {/* v1.16.8 — explicit remember control. Stating an allergy in + chat used to leave no durable trace unless a narrow + pattern pass happened to match the phrasing; this stores + the message into the editable self-context (Settings → AI) + on one tap, so it rides every future system prompt. Only + persisted messages get the control (an optimistic bubble + has no id yet), and only when the text fits the + self-context field cap. */} + {messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS && ( + + )} +
+ {avatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
+ ); + } + + // v1.4.25 W5 — map server-emitted error codes to specific Coach + // i18n keys. Distinct user-quota and provider-rate-limit copy so the + // user understands daily-cap (resets at UTC midnight) vs. transient + // provider load (retry in ~5 min). Codes that have no dedicated + // translation fall back to the generic provider-unavailable copy. + const errorKey = errorCode ? errorCodeToI18nKey(errorCode) : null; + const errorMessage = errorKey ? t(errorKey, {}) : null; + // When a translated message comes back unchanged (i.e. key missing) + // we fall back to a generic provider error string so the bubble + // doesn't surface raw `coach.http.503` text to the user. + const safeError = + errorMessage && errorMessage !== errorKey + ? errorMessage + : errorCode + ? t("insights.coach.errorProvider") + : null; + + const keyValues = metricSource?.keyValues ?? []; + // v1.12.0 — the provenance disclosure surfaces whenever there is any + // grounding to show: the source chips (metrics/windows) and/or the + // raw key-values. `SourceChips` itself returns null when the envelope + // carries neither metric nor window, so we mirror that condition here + // to decide whether the `
` shell renders at all. + const hasChips = + !!metricSource && + ((metricSource.metrics?.length ?? 0) > 0 || + (metricSource.windows?.length ?? 0) > 0); + const hasProvenance = hasChips || keyValues.length > 0; + + // v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance- + // grounded `metric:` chart under a SETTLED assistant turn. Skipped + // while streaming / in-flight / errored / on a refusal; a no-op when no + // grounded token is present (provider-agnostic — reads the inline token). + const chartTokens = + !streaming && !inProgress && !errorCode && providerType !== "refusal" + ? selectCoachChartTokens(content, metricSource?.metrics) + : []; + + return ( +
+ +
+ {/* v1.19.1 (C3) — the live turn shows the classic typing animation + (three pulsing dots) while it is still thinking with no prose + yet, restoring the writing/typing indicator the maintainer + prefers over the "Denke nach / Nachgedacht" reasoning disclosure. + The dots render inside the prose bubble below; the disclosure is + retired. Persisted bubbles never stream, so history stays settled. */} + {/* The prose bubble. While the live turn is still thinking with no + prose the bubble carries the typing animation; once tokens land + it swaps to the streamed prose without a layout jump. */} + {(content || safeError || inProgress) && ( +
+ {/* v1.4.25 W5b — strip stray Metric/enum leak tokens from the + assistant prose. v1.18.9 — the live turn streams in + word-by-word with a soft fade (); a settled + / persisted turn renders as plain text. */} + {/* v1.16.4 — a failed turn with no streamed prose renders the + error copy INSIDE the bubble; with partial prose the prose + keeps the bubble and the error stays a caption. */} + {content ? ( + + ) : inProgress ? ( + + ) : safeError ? ( + {safeError} + ) : ( + "" + )} +
+ )} + {safeError && content && ( +

{safeError}

+ )} + {/* v1.22 (W5) — accompanying chart(s). The token was already + stripped from the prose by ; here it mounts the + real, self-fetching Recharts chart for the user's own series. + The model never emits data — only a metric identifier from a + closed allowlist, intersected with the turn's grounded metrics. */} + {chartTokens.length > 0 && ( +
+ {chartTokens.map((token) => { + const metric = tokenToMetric(token); + return ( +
+ +
+ ); + })} +
+ )} + {/* v1.18.6 — a "no provider configured anywhere" turn is a + setup gap, not a transient failure: surface a direct link to + Settings → AI so the Coach guides the user into BYOK / local + setup instead of inviting an endless retry. */} + {errorCode === "coach.provider.none" && ( + + {t("insights.coach.errorNoProviderAction")} + + )} + {/* v1.12.0 — collapse the whole provenance block ("what was + included") behind one disclosure, collapsed by default. The + source chips used to render fully expanded above the + evidence `
`, so the grounding context was often + taller than the answer itself. Folding the chips + the raw + key-values into a single closed disclosure keeps the reply + the focus and lets the user expand the grounding on demand — + and removes the always-on chip row that duplicated what the + disclosure already names. The disclosure surfaces whenever + there is any provenance to show (chips and/or key-values). */} + {hasProvenance && ( +
+ setEvidenceOpen((e.target as HTMLDetailsElement).open) + } + // v1.4.27 F14 — always closed by default. The `open` + // attribute was previously tied to a per-user pref that + // surfaced raw values unconditionally; that pref is now + // retired and the disclosure is a true progressive- + // disclosure surface — the user clicks to expand. + // + // v1.4.27 MB3 / CF-32 — the `open` attribute is now + // controlled from local state so the summary's + // `aria-expanded` stays in lock-step. The native disclosure + // semantics (Enter / Space toggle) are preserved. + className={cn( + "border-border/50 bg-muted/30 group rounded-md border", + "px-2.5 py-1.5 text-xs", + )} + > + + +
+ {/* v1.12.0 — the source chips now live inside the + disclosure so they expand with the rest of the + grounding instead of always painting above the + answer. */} + {hasChips && metricSource && ( + + )} + {keyValues.length > 0 && ( +
    + {keyValues.map((kv, idx) => ( +
  • + {/* v1.4.25 W5 — `kv.label` (e.g. "avg7 systolic") + was rendered prefixed to every row, repeating + framing the disclosure heading already gives. + Drop the label and lead with the value; the + window stays as a parenthetical tail so the row + still answers "over what timeframe?". */} + + {kv.value} + {kv.unit ? ` ${kv.unit}` : ""} + + {kv.window && ( + + {" "} + ({kv.window}) + + )} +
  • + ))} +
+ )} +
+
+ )} + {/* v1.18.1 (Workstream C) — one-tap cadence-suggestion action + card. Live from the streaming hook, or restored from the + persisted message provenance on reload. Not shown on + in-flight or errored turns. */} + {!inProgress && + !errorCode && + (() => { + const sug = suggestion ?? metricSource?.suggestion ?? null; + return sug ? : null; + })()} + {/* v1.22 (W7/W6) — generalised confirm-card action. Live from the + streaming hook, or restored from persisted message provenance on + reload. Mirrors the reminder-suggestion block above. */} + {!inProgress && + !errorCode && + (() => { + const action = + suggestedAction ?? metricSource?.suggestedAction ?? null; + return action ? : null; + })()} + {/* v1.18.9 — quiet per-message token footer. The just-finished + streaming turn reads the `done.usage` envelope; a persisted / + reloaded turn reads the message's own `tokensUsed` + `model`. + Skipped on in-flight, errored, and refusal turns. */} + {!inProgress && !errorCode && providerType !== "refusal" && ( + + )} + {/* v1.22 — per-message hover action row. Icon-only, muted until the + bubble is hovered / focused on pointer devices; always visible on + touch (no hover to reveal it). Copy + Read-aloud + Good / Bad + feedback + Try-again, with the timestamp trailing. Only settled + persisted assistant turns get the row (skipped for refusals, + errors, in-flight stream bubbles). */} + {!inProgress && !errorCode && providerType !== "refusal" && content && ( +
+ + {!streaming && } + {messageId && } + {!streaming && onRegenerate && ( + + )} + {createdAt && } +
+ )} +
+
+ ); +} + +/** + * v1.16.8 — per-message remember control under the user bubble. + * + * One tap stores the message text into the structured self-context via + * `POST /api/coach/about-me/adopt` (no `question` — the server matches + * the target field from the text itself: an allergy statement lands on + * the allergies field, a stated condition on conditions, everything + * else on the coach-focus slot). The stored text is visible and + * editable under Settings → AI and rides every future Coach system + * prompt. Settled states mirror `SelfContextAdoptOffer`: a short + * confirmation replaces the button after an adoption or a server-side + * dedupe; a failure surfaces a toast and the button stays tappable. + */ +function RememberUserMessage({ content }: { content: string }) { + const { t } = useTranslations(); + const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null); + // On settle the button unmounts while it holds focus, which would + // drop keyboard focus to . The confirmation paragraph takes + // the focus instead (`tabIndex={-1}` + programmatic focus) so the + // reading position survives the swap. + const statusRef = useRef(null); + useEffect(() => { + if (settled) statusRef.current?.focus(); + }, [settled]); + + const remember = useMutation({ + mutationFn: async () => { + return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", { + answer: content, + }); + }, + onSuccess: (data) => { + setSettled(data.adopted ? "adopted" : "duplicate"); + }, + onError: () => { + toast.error(t("insights.coach.rememberMessage.failed")); + }, + }); + + if (settled) { + return ( +

+

+ ); + } + + return ( + + ); +} + +/** + * v1.16.1 — classic chat typing indicator: three dots pulsing in + * sequence inside the assistant bubble, shown only between submit and + * the first streamed token. Uses the stock `animate-pulse` keyframe + * with staggered delays so no custom keyframe is introduced; + * `motion-reduce` freezes the dots and the `label` stays as the + * screen-reader text either way. + * + * Exported since v1.16.5: the guided clarifying-question bubble replays + * the same indicator before a deterministic question reveals, so the + * scripted turns share one rhythm with the streamed ones. + */ +export function TypingDots({ label }: { label: string }) { + return ( + + {label} + + ); +} + +interface CoachMessageFeedbackProps { + messageId: string; +} + +function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) { + const { t } = useTranslations(); + const [submittedRating, setSubmittedRating] = useState< + "helpful" | "unhelpful" | null + >(null); + + const submit = useMutation({ + mutationFn: async (rating: "helpful" | "unhelpful") => { + try { + await apiPost(`/api/insights/chat/messages/${messageId}/feedback`, { + rating, + }); + } catch (err) { + // Treat 409 (already_rated) as a successful no-op so the user + // never sees an error toast for double-clicking the same chip. + if (!(err instanceof ApiError && err.status === 409)) { + throw err; + } + } + return rating; + }, + onSuccess: (rating) => setSubmittedRating(rating), + // v1.16.4 — a failed rating used to fail silently; the chips stayed + // tappable with no signal that nothing was recorded. + onError: () => { + toast.error(t("insights.coach.feedbackError")); + }, + }); + + // v1.22 — icon-only feedback in the per-message action row. After a rating + // lands, the chosen thumb stays visible in its confirming colour (the other + // is dropped) with an `sr-only` thanks so the signal is legible without a + // text caption breaking the icon row. + if (submittedRating) { + return ( + + {t("insights.coach.feedbackThanks")} + {submittedRating === "helpful" ? ( + + ); + } + + const helpfulLabel = t("insights.coach.feedbackHelpful"); + const unhelpfulLabel = t("insights.coach.feedbackUnhelpful"); + return ( +
+ + +
+ ); +} diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index a911d8537..feda6a3cb 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -1,65 +1,30 @@ "use client"; -import { - Fragment, - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from "react"; -import Link from "next/link"; -import { useMutation } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { - BookmarkPlus, - Bot, - Check, - ChevronRight, - Clock, - Copy, - Loader2, - RotateCcw, - Sparkles, - ThumbsDown, - ThumbsUp, - User, - Volume2, - VolumeX, -} from "lucide-react"; +import { Fragment, useEffect, useMemo, useRef, useState } from "react"; +import { Sparkles } from "lucide-react"; import { cn } from "@/lib/utils"; import { scrollBehaviorForUser } from "@/lib/motion"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { ApiError, apiPost } from "@/lib/api/api-fetch"; -import { useAuth } from "@/hooks/use-auth"; -import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me"; -import { - parseChartTokens, - stripChartTokens, - tokenToMetric, - type ChartToken, -} from "@/lib/insights/chart-tokens"; -import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; -import { ProseBlocks } from "@/components/insights/prose-blocks"; +import { useTranslations } from "@/lib/i18n/context"; -import { SourceChips } from "./source-chips"; import { PlanProposalCards } from "./plan-proposal-card"; -import { ReminderSuggestionCard } from "./reminder-suggestion-card"; -import { SuggestedActionCard } from "./suggested-action-card"; -import { StreamedProse } from "./streamed-prose"; -import { MessageTokenFooter } from "./message-token-footer"; +import { ChatBubble } from "./chat-bubble"; import type { CoachConversationDetailDTO, CoachOptimisticUserMessage, CoachStreamingMessage, } from "./use-coach"; -import type { - CoachMessageDTO, - CoachProvenanceMetric, -} from "@/lib/ai/coach/types"; +import type { CoachMessageDTO } from "@/lib/ai/coach/types"; + +// v1.28.26 file-size split (pure code motion): the bubble renderer + +// per-message actions live in `chat-bubble.tsx`, the read-aloud stack in +// `read-aloud.tsx`. The previously public names re-export from here so +// external call sites are unchanged. +export { + TypingDots, + errorCodeToI18nKey, + selectCoachChartTokens, +} from "./chat-bubble"; /** * v1.4.20 phase B2b — message-thread renderer. @@ -195,547 +160,6 @@ function isPinnedToBottom(el: HTMLElement, slack = 64): boolean { return el.scrollHeight - el.scrollTop - el.clientHeight <= slack; } -/** - * v1.4.25 W5 — map server-emitted error codes to specific Coach i18n - * keys. The chat route distinguishes the daily user-quota - * (`coach.budget.exceeded`, returned as a JSON 429) from the provider - * rate-limit (`coach.provider.rate_limited`, streamed as an SSE error - * frame). Both used to surface as the generic provider-unavailable - * copy; we now route each to its dedicated translation so the user - * understands whether the limit is on their side (reset at UTC - * midnight) or transient on the provider side (retry in ~5 min). - * - * Exported so the resolver can be pinned by unit tests without - * standing up the whole thread renderer. - */ -export function errorCodeToI18nKey(code: string): string { - switch (code) { - case "coach.budget.exceeded": - return "insights.coach.dailyLimitBody"; - case "coach.provider.rate_limited": - return "insights.coach.providerRateLimitBody"; - case "coach.network": - // v1.4.43 QoL (M6) — a dropped network is the user's local - // problem and needs a different next action ("come back online") - // than a provider failure ("try again in a moment"). Split out - // so the user sees the actionable copy in the offline branch. - return "insights.coach.errorNetwork"; - case "coach.provider.credential_expired": - // v1.11.0 W1 — the user's primary AI provider credential is dead - // (auth-class failure). The next action is "reconnect", not "try - // again later", so it carries its own copy pointing at Settings. - return "insights.coach.errorCredentialExpired"; - case "coach.provider.none": - // v1.18.6 — no AI provider is configured anywhere, so "try again - // in a moment" is the wrong instruction (it will 422 forever). Send - // the user to guided setup with a one-line explainer + a Settings - // link rendered alongside the bubble copy. - return "insights.coach.errorNoProvider"; - case "coach.provider.unavailable": - case "coach.provider.empty": - case "coach.stream": - return "insights.coach.errorProvider"; - default: - // Forward-compat: try `insights.coach.` for codes that - // ship their own translation (e.g. legacy `errorProvider`). - return `insights.coach.${code}`; - } -} - -/** - * v1.22 (W5) — Coach accompanying charts, Phase 1: the renderer half. - * - * The chart-token mechanism (`chart-tokens.ts`) was prepared for the - * Insights prose but its render path was never wired for the Coach — - * `stripChartTokens` cleaned tokens out of the prose, but `parseChartTokens` - * had no caller, so a `metric:` token the model emitted rendered no - * chart. This map activates the render half for the Coach. - * - * Each entry pairs an allowlisted chart token with the Coach provenance - * TOPIC that the snapshot stamps when it actually drew on that metric. A - * chart only renders when its topic is present in `metricSource.metrics`, so - * the series is grounded twice over: the closed allowlist drops a - * hallucinated token, and the provenance intersect drops a metric the turn - * never saw (and which therefore has no data). The chart itself self-fetches - * the user's real series from `/api/measurements` — the model never emits a - * data point. - * - * Only MeasurementType-backed tokens that render through the generic, - * self-fetching `` are listed. The synthetic `metric:MOOD` - * token (served by a separate ``) and the allowlist's reserved - * score classes are intentionally omitted from Phase 1. - * - * The prompt clause that tells the model it MAY emit one such token is a - * separate concern (the narrative/prompt workstream); this is render-only, - * provider-agnostic (it reads the plain inline token, so it works for every - * provider including the codex inline-text path), and a graceful no-op when - * no grounded token is present. - */ -const CHART_TOKEN_PROVENANCE: Partial< - Record -> = { - "metric:WEIGHT": "weight", - "metric:BLOOD_PRESSURE_SYS": "bp", - "metric:BLOOD_PRESSURE_DIA": "bp", - "metric:PULSE": "pulse", - "metric:BODY_FAT": "body_fat", - "metric:SLEEP_DURATION": "sleep", - "metric:ACTIVITY_STEPS": "steps", - "metric:BLOOD_GLUCOSE": "glucose", - "metric:TOTAL_BODY_WATER": "total_body_water", - "metric:BONE_MASS": "bone_mass", - "metric:OXYGEN_SATURATION": "spo2", - "metric:HEART_RATE_VARIABILITY": "hrv", - "metric:RESTING_HEART_RATE": "resting_hr", - "metric:ACTIVE_ENERGY_BURNED": "active_energy", - "metric:FLIGHTS_CLIMBED": "flights", - "metric:WALKING_RUNNING_DISTANCE": "distance", - "metric:VO2_MAX": "vo2_max", - "metric:BODY_TEMPERATURE": "body_temp", - "metric:FAT_FREE_MASS": "fat_free_mass", - "metric:FAT_MASS": "fat_mass", - "metric:MUSCLE_MASS": "muscle_mass", - "metric:LEAN_BODY_MASS": "lean_body_mass", - "metric:BODY_MASS_INDEX": "bmi", - "metric:VISCERAL_FAT": "visceral_fat", - "metric:SKIN_TEMPERATURE": "skin_temp", - "metric:RESPIRATORY_RATE": "respiratory_rate", - "metric:PULSE_WAVE_VELOCITY": "pulse_wave_velocity", - "metric:VASCULAR_AGE": "vascular_age", - "metric:WALKING_HEART_RATE_AVERAGE": "walking_hr", - "metric:WALKING_ASYMMETRY": "walking_asymmetry", - "metric:WALKING_DOUBLE_SUPPORT": "walking_double_support", - "metric:WALKING_STEP_LENGTH": "walking_step_length", - "metric:WALKING_SPEED": "walking_speed", - "metric:AUDIO_EXPOSURE_ENV": "audio_env", - "metric:AUDIO_EXPOSURE_HEADPHONE": "audio_headphone", - "metric:AUDIO_EXPOSURE_EVENT": "audio_event", - "metric:TIME_IN_DAYLIGHT": "daylight", -}; - -/** Max charts rendered under a single Coach turn (keeps the reply scannable). */ -const MAX_COACH_CHARTS = 2; - -/** - * Pure selection of the chart tokens to render under an assistant turn: - * allowlist-parsed, intersected with the turn's grounded provenance topics, - * de-duplicated by metric, and capped. Exported for unit tests so the - * grounding contract is pinned without standing up the chart component. - */ -export function selectCoachChartTokens( - content: string, - metrics: readonly CoachProvenanceMetric[] | undefined, -): ChartToken[] { - const grounded = new Set(metrics ?? []); - const out: ChartToken[] = []; - const seen = new Set(); - for (const token of parseChartTokens(content)) { - const topic = CHART_TOKEN_PROVENANCE[token]; - if (!topic || !grounded.has(topic)) continue; - const metric = tokenToMetric(token); - if (seen.has(metric)) continue; - seen.add(metric); - out.push(token); - if (out.length >= MAX_COACH_CHARTS) break; - } - return out; -} - -/** - * Chart tokens whose canonical measurement-type label has no dedicated - * `measurements.type*` key — route them to the closest existing key so the - * chart header reads cleanly rather than echoing the raw enum. - */ -const CHART_TITLE_KEY_OVERRIDE: Record = { - BLOOD_PRESSURE_SYS: "measurements.typeBloodPressure", - BLOOD_PRESSURE_DIA: "measurements.typeBloodPressure", -}; - -/** Localised chart header for a MeasurementType, mirroring the snapshot's - * `measurements.type` convention with a readable fallback. */ -function coachChartTitle( - metric: string, - t: (key: string, vars?: Record) => string, -): string { - const override = CHART_TITLE_KEY_OVERRIDE[metric]; - if (override) { - const resolved = t(override); - if (resolved !== override) return resolved; - } - const camel = metric - .toLowerCase() - .split("_") - .map((part, i) => - i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1), - ) - .join(""); - const key = `measurements.type${camel.charAt(0).toUpperCase()}${camel.slice(1)}`; - const resolved = t(key); - return resolved === key ? metric.replace(/_/g, " ").toLowerCase() : resolved; -} - -/** - * v1.22 (W5) — feature-detect the browser Speech Synthesis API SSR-safe. - * `useSyncExternalStore` returns false on the server + first client paint - * (so the read-aloud button is absent in markup) and resolves to the real - * capability after hydration, mirroring the mic button's detection pattern - * — no hydration mismatch warning, no `connect-src` (it runs in-browser). - */ -function useSpeechSynthesisSupported(): boolean { - return useSyncExternalStore( - () => () => {}, - () => - typeof window !== "undefined" && - "speechSynthesis" in window && - "SpeechSynthesisUtterance" in window, - () => false, - ); -} - -/** - * v1.22.1 — feature-detect the async Clipboard API SSR-safe, mirroring the - * Speech-Synthesis detection above. `navigator.clipboard` is `undefined` on - * plain-HTTP self-hosts (a supported insecure-context config), so the copy - * button must be absent there rather than rendering and error-toasting on tap. - * `useSyncExternalStore` returns false on the server + first client paint and - * resolves to the real capability after hydration — no hydration mismatch. - */ -function useClipboardSupported(): boolean { - return useSyncExternalStore( - () => () => {}, - () => - typeof navigator !== "undefined" && - typeof navigator.clipboard?.writeText === "function", - () => false, - ); -} - -/** - * v1.22 — score a synthesis voice for a target language. Higher is better; - * a negative score marks a legacy/compact voice we would rather skip. Pure - * (no browser globals) so the ranking contract is unit-testable. Exported - * for the read-aloud voice-selection test. - * - * The default OS voice is often a legacy "compact" engine (Anna, Albert, - * Zarvox, eSpeak) that reads robotically. We prefer, in order: an Apple - * enhanced/premium or Siri voice, a Microsoft "Natural"/"Online" (Edge) - * voice, a "Google …" (Chrome) voice, and a small set of known-good names - * (Samantha). An exact lang-REGION match (de-DE/en-US) outweighs a bare - * language match. `localService` only breaks a tie. Known-bad names are - * penalised so they never win over a neutral remote voice. - */ -export function scoreSpeechVoice( - voice: { name: string; lang: string; localService?: boolean }, - lang: string, -): number { - const base = lang.split("-")[0].toLowerCase(); - const vlang = voice.lang.toLowerCase().replace("_", "-"); - if (!vlang.startsWith(base)) return -Infinity; - const name = voice.name.toLowerCase(); - let score = 0; - - // Exact lang-REGION match (de-DE, en-US) over a bare language match. - if (lang.includes("-") && vlang === lang.toLowerCase()) score += 40; - - // High-quality engines. - if (/\b(enhanced|premium)\b/.test(name)) score += 60; - if (name.includes("siri")) score += 55; - if (name.includes("natural") || name.includes("online")) score += 50; - if (name.startsWith("google")) score += 35; - if (name.includes("samantha")) score += 30; - - // Known-bad legacy / compact engines. - if (name.includes("compact")) score -= 80; - if (/\b(albert|zarvox|espeak|anna|fred|ralph|bahh|trinoids)\b/.test(name)) { - score -= 100; - } - - // Tie-breaker only: a local voice has no network latency. - if (voice.localService) score += 1; - - return score; -} - -/** - * v1.22 — pick the best-scoring installed voice for `lang`, or null when - * none beats a legacy/compact default (graceful fallback to the browser - * default). Pure helper, exported for the ranking test. - */ -export function pickSpeechVoice( - voices: SpeechSynthesisVoice[], - lang: string, -): SpeechSynthesisVoice | null { - let best: SpeechSynthesisVoice | null = null; - let bestScore = 0; // require a positive score to override the default - for (const v of voices) { - const s = scoreSpeechVoice(v, lang); - if (s > bestScore) { - bestScore = s; - best = v; - } - } - return best; -} - -/** - * v1.22 (W5) — read the assistant prose aloud via the browser's - * `SpeechSynthesis`. Client-only, no dependency, no egress, CSP-clean. A - * single in-flight utterance: a new `speak` cancels any prior one; `toggle` - * stops while speaking. - * - * v1.22 — natural-voice selection. The browser default is frequently a - * legacy/compact engine that reads robotically; instead of only stamping - * `utterance.lang` we enumerate `getVoices()`, rank the candidates for the - * locale (`pickSpeechVoice`), and assign the winner. The voice list is async - * (empty on the first call, and on iOS Safari it can stay empty until the - * first user-gesture speak), so we subscribe to `voiceschanged` and also - * re-read it lazily on the first toggle. The chosen voice is cached per - * locale in a ref so the ranking runs once. - */ -function useReadAloud(): { - supported: boolean; - speaking: boolean; - toggle: (text: string, lang: string) => void; -} { - const supported = useSpeechSynthesisSupported(); - const [speaking, setSpeaking] = useState(false); - // Cache the resolved voice per locale so the ranking runs once. - const voiceByLocaleRef = useRef>( - new Map(), - ); - - // Pre-warm the voice list + keep it fresh: the list arrives asynchronously - // and `voiceschanged` fires when the OS finishes loading installed voices. - useEffect(() => { - if (!supported) return; - const synth = window.speechSynthesis; - const refresh = () => { - // A late-arriving list can surface a better voice than an early read; - // clear the cache so the next toggle re-ranks against the full set. - voiceByLocaleRef.current.clear(); - }; - synth.getVoices(); - synth.addEventListener?.("voiceschanged", refresh); - return () => synth.removeEventListener?.("voiceschanged", refresh); - }, [supported]); - - // Stop any in-flight utterance if the bubble unmounts (thread reset, nav). - useEffect(() => { - return () => { - if (typeof window !== "undefined" && "speechSynthesis" in window) { - window.speechSynthesis.cancel(); - } - }; - }, []); - - const resolveVoice = useCallback( - (synth: SpeechSynthesis, lang: string): SpeechSynthesisVoice | null => { - const cached = voiceByLocaleRef.current.get(lang); - if (cached !== undefined) return cached; - const voices = synth.getVoices(); - // An empty list (first call / iOS pre-gesture) is not cached so a later - // toggle re-ranks once the OS has populated the voices. - if (voices.length === 0) return null; - const chosen = pickSpeechVoice(voices, lang); - voiceByLocaleRef.current.set(lang, chosen); - return chosen; - }, - [], - ); - - const toggle = useCallback( - (text: string, lang: string) => { - if (!supported) return; - const synth = window.speechSynthesis; - if (speaking) { - synth.cancel(); - setSpeaking(false); - return; - } - const trimmed = text.trim(); - if (!trimmed) return; - synth.cancel(); - const utterance = new SpeechSynthesisUtterance(trimmed); - const voice = resolveVoice(synth, lang); - if (voice) { - utterance.voice = voice; - utterance.lang = voice.lang; - } else { - utterance.lang = lang; - } - // A natural cadence: default rate, neutral pitch. - utterance.rate = 1.0; - utterance.pitch = 1.0; - utterance.onend = () => setSpeaking(false); - utterance.onerror = () => setSpeaking(false); - setSpeaking(true); - synth.speak(utterance); - }, - [supported, speaking, resolveVoice], - ); - - return { supported, speaking, toggle }; -} - -/** - * v1.22 — shared styling for the icon-only per-message action buttons. One - * tap convention across Copy / Read-aloud / feedback / Try-again: a 44px - * mobile tap floor (WCAG 2.5.5) collapsing to a compact 32px desktop target, - * muted until hover/focus, with the standard focus ring. - */ -const COACH_ICON_BUTTON = cn( - "text-muted-foreground hover:text-foreground focus-visible:ring-ring/50", - "inline-flex size-11 min-w-11 shrink-0 items-center justify-center rounded", - "outline-none focus-visible:ring-2 disabled:opacity-50 sm:size-8 sm:min-w-8", -); - -/** - * v1.22 (W5) — read-aloud toggle for a settled assistant turn. Hidden when - * the browser has no Speech Synthesis support. Speaks `stripChartTokens` - * (the same text the prose shows), so stray tokens are never voiced. - */ -function ReadAloudButton({ content }: { content: string }) { - const { t, locale } = useTranslations(); - const { supported, speaking, toggle } = useReadAloud(); - if (!supported) return null; - const label = speaking - ? t("insights.coach.readAloudStop") - : t("insights.coach.readAloud"); - return ( - - ); -} - -/** - * v1.22 — copy a message to the clipboard. Assistant prose is copied through - * `stripChartTokens` (the same text the bubble shows) so inline chart tokens - * are never pasted; user text is verbatim. A brief check-mark + toast confirm - * the copy. Hidden when the Clipboard API is unavailable (insecure context). - */ -function CopyMessageButton({ - content, - strip, -}: { - content: string; - strip: boolean; -}) { - const { t } = useTranslations(); - const supported = useClipboardSupported(); - const [copied, setCopied] = useState(false); - const handle = useCallback(async () => { - const text = strip ? stripChartTokens(content) : content; - try { - await navigator.clipboard.writeText(text); - setCopied(true); - window.setTimeout(() => setCopied(false), 1500); - } catch { - toast.error(t("insights.coach.copyMessageError")); - } - }, [content, strip, t]); - // Absent on insecure-context (plain-HTTP) self-hosts where the Clipboard API - // is undefined — render nothing rather than error-toast on tap. - if (!supported) return null; - const label = t("insights.coach.copyMessage"); - return ( - - ); -} - -/** - * v1.22 — "Try again": re-run the user turn that produced this assistant - * reply so the user can regenerate an unsatisfying answer. The thread hands - * down the preceding user message; the surface resubmits it as a fresh turn. - */ -function TryAgainButton({ onRegenerate }: { onRegenerate: () => void }) { - const { t } = useTranslations(); - const label = t("insights.coach.regenerate"); - return ( - - ); -} - -/** - * v1.22 (W5) — hover/tap timestamp for a persisted message bubble. A small, - * calm clock affordance; the locale-aware date + time surfaces in a tooltip - * on hover or keyboard focus (desktop) and on tap (mobile, toggled state). - * Pure CSS + one boolean — no portal, no Radix dependency, SSR-safe. - */ -function BubbleTimestamp({ - iso, - align = "start", -}: { - iso: string; - align?: "start" | "end"; -}) { - const { t } = useTranslations(); - const formatters = useFormatters(); - const [open, setOpen] = useState(false); - const label = formatters.dateTime(iso); - return ( - - - - {label} - - - ); -} - export function MessageThread({ conversation, streaming, @@ -1038,676 +462,3 @@ export function MessageThread({ ); } - -interface ChatBubbleProps { - role: "user" | "assistant"; - content: string; - metricSource?: import("@/lib/ai/coach/types").CoachProvenance | null; - /** - * v1.18.1 (Workstream C) — live cadence suggestion from the streaming - * hook. Persisted messages carry it on `metricSource.suggestion` - * instead; the bubble falls back to that so the card survives reload. - */ - suggestion?: import("@/lib/ai/coach/types").CoachSuggestion | null; - /** - * v1.22 (W7/W6) — live generalised confirm-card action from the streaming - * hook. Persisted messages carry it on `metricSource.suggestedAction`; the - * bubble falls back to that so the card survives reload. - */ - suggestedAction?: - import("@/lib/ai/coach/suggest-action").CoachSuggestedAction | null; - providerType?: string | null; - inProgress?: boolean; - errorCode?: string | null; - /** - * v1.4.23 H7 — present only on persisted assistant messages. - * Streaming bubbles (no message id yet) skip the thumbs row so the - * user can't rate before the message lands on disk. - */ - messageId?: string; - /** - * v1.18.9 — true for the live streaming assistant turn. Drives the - * word-by-word prose fade (``); persisted bubbles render - * settled plain text. - */ - streaming?: boolean; - /** - * v1.18.9 — per-turn token usage for the just-finished streaming bubble - * (from `done.usage`). Persisted bubbles read `tokensUsed` / `model` - * instead. - */ - usage?: import("./use-coach").CoachUsage | null; - /** - * v1.18.9 — persisted per-message token count + model, for the quiet - * token footer on reload. Null on user turns, refusals, and older rows. - */ - tokensUsed?: number | null; - model?: string | null; - /** - * v1.22 (W5) — ISO creation timestamp for the persisted bubble's - * hover/tap timestamp tooltip. Absent on optimistic + streaming bubbles - * (no persisted time yet), so those render no timestamp. - */ - createdAt?: string; - /** - * v1.22 — bound "Try again" callback for a settled assistant turn (the - * thread closes over the preceding user message). Absent on user / streaming - * / refusal turns and when the surface supplies no regenerate handler. - */ - onRegenerate?: () => void; -} - -function ChatBubble({ - role, - content, - metricSource, - suggestion, - suggestedAction, - providerType, - inProgress, - errorCode, - messageId, - streaming, - usage, - tokensUsed, - model, - createdAt, - onRegenerate, -}: ChatBubbleProps) { - const { t } = useTranslations(); - const { user } = useAuth(); - // v1.4.27 B7 / L3 — pair the evidence `
` and its disclosed - // list explicitly so screen-readers announce the panel relationship. - const evidencePanelId = useId(); - // v1.4.27 MB3 / CF-32 — track the disclosure state in React so the - // summary can carry an accurate `aria-expanded`. Native `
` - // reflects its open state via the `open` attribute, but that does - // not surface as `aria-expanded` on the summary by default; screen - // readers still need the explicit attribute to announce the panel - // as expanded vs collapsed. - const [evidenceOpen, setEvidenceOpen] = useState(false); - if (role === "user") { - // v1.5.5 — pull the user's self-hosted avatar so the user - // bubble matches the Coach avatar in size and visual weight. - // Falls back to initials when the user has not uploaded an - // avatar. Replaces the Gravatar leak (v1.4.22 B3). - const avatarUrl = user?.avatarUrl ?? null; - const initials = user?.username - ? user.username.slice(0, 2).toUpperCase() - : null; - return ( -
-
-
- {/* v1.22 (W5) — render real paragraph blocks so a multi-line - message reads as paragraphs, not one run-on block. User text - is verbatim: no chart-token strip, no Learn linkify. */} - -
- {/* v1.22 — per-message action row: Copy, then the timestamp - trailing. Muted until the bubble is hovered / focused on pointer - devices; always visible on touch (no hover to reveal it). */} -
- - {createdAt && } -
- {/* v1.16.8 — explicit remember control. Stating an allergy in - chat used to leave no durable trace unless a narrow - pattern pass happened to match the phrasing; this stores - the message into the editable self-context (Settings → AI) - on one tap, so it rides every future system prompt. Only - persisted messages get the control (an optimistic bubble - has no id yet), and only when the text fits the - self-context field cap. */} - {messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS && ( - - )} -
- {avatarUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( - - )} -
- ); - } - - // v1.4.25 W5 — map server-emitted error codes to specific Coach - // i18n keys. Distinct user-quota and provider-rate-limit copy so the - // user understands daily-cap (resets at UTC midnight) vs. transient - // provider load (retry in ~5 min). Codes that have no dedicated - // translation fall back to the generic provider-unavailable copy. - const errorKey = errorCode ? errorCodeToI18nKey(errorCode) : null; - const errorMessage = errorKey ? t(errorKey, {}) : null; - // When a translated message comes back unchanged (i.e. key missing) - // we fall back to a generic provider error string so the bubble - // doesn't surface raw `coach.http.503` text to the user. - const safeError = - errorMessage && errorMessage !== errorKey - ? errorMessage - : errorCode - ? t("insights.coach.errorProvider") - : null; - - const keyValues = metricSource?.keyValues ?? []; - // v1.12.0 — the provenance disclosure surfaces whenever there is any - // grounding to show: the source chips (metrics/windows) and/or the - // raw key-values. `SourceChips` itself returns null when the envelope - // carries neither metric nor window, so we mirror that condition here - // to decide whether the `
` shell renders at all. - const hasChips = - !!metricSource && - ((metricSource.metrics?.length ?? 0) > 0 || - (metricSource.windows?.length ?? 0) > 0); - const hasProvenance = hasChips || keyValues.length > 0; - - // v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance- - // grounded `metric:` chart under a SETTLED assistant turn. Skipped - // while streaming / in-flight / errored / on a refusal; a no-op when no - // grounded token is present (provider-agnostic — reads the inline token). - const chartTokens = - !streaming && !inProgress && !errorCode && providerType !== "refusal" - ? selectCoachChartTokens(content, metricSource?.metrics) - : []; - - return ( -
- -
- {/* v1.19.1 (C3) — the live turn shows the classic typing animation - (three pulsing dots) while it is still thinking with no prose - yet, restoring the writing/typing indicator the maintainer - prefers over the "Denke nach / Nachgedacht" reasoning disclosure. - The dots render inside the prose bubble below; the disclosure is - retired. Persisted bubbles never stream, so history stays settled. */} - {/* The prose bubble. While the live turn is still thinking with no - prose the bubble carries the typing animation; once tokens land - it swaps to the streamed prose without a layout jump. */} - {(content || safeError || inProgress) && ( -
- {/* v1.4.25 W5b — strip stray Metric/enum leak tokens from the - assistant prose. v1.18.9 — the live turn streams in - word-by-word with a soft fade (); a settled - / persisted turn renders as plain text. */} - {/* v1.16.4 — a failed turn with no streamed prose renders the - error copy INSIDE the bubble; with partial prose the prose - keeps the bubble and the error stays a caption. */} - {content ? ( - - ) : inProgress ? ( - - ) : safeError ? ( - {safeError} - ) : ( - "" - )} -
- )} - {safeError && content && ( -

{safeError}

- )} - {/* v1.22 (W5) — accompanying chart(s). The token was already - stripped from the prose by ; here it mounts the - real, self-fetching Recharts chart for the user's own series. - The model never emits data — only a metric identifier from a - closed allowlist, intersected with the turn's grounded metrics. */} - {chartTokens.length > 0 && ( -
- {chartTokens.map((token) => { - const metric = tokenToMetric(token); - return ( -
- -
- ); - })} -
- )} - {/* v1.18.6 — a "no provider configured anywhere" turn is a - setup gap, not a transient failure: surface a direct link to - Settings → AI so the Coach guides the user into BYOK / local - setup instead of inviting an endless retry. */} - {errorCode === "coach.provider.none" && ( - - {t("insights.coach.errorNoProviderAction")} - - )} - {/* v1.12.0 — collapse the whole provenance block ("what was - included") behind one disclosure, collapsed by default. The - source chips used to render fully expanded above the - evidence `
`, so the grounding context was often - taller than the answer itself. Folding the chips + the raw - key-values into a single closed disclosure keeps the reply - the focus and lets the user expand the grounding on demand — - and removes the always-on chip row that duplicated what the - disclosure already names. The disclosure surfaces whenever - there is any provenance to show (chips and/or key-values). */} - {hasProvenance && ( -
- setEvidenceOpen((e.target as HTMLDetailsElement).open) - } - // v1.4.27 F14 — always closed by default. The `open` - // attribute was previously tied to a per-user pref that - // surfaced raw values unconditionally; that pref is now - // retired and the disclosure is a true progressive- - // disclosure surface — the user clicks to expand. - // - // v1.4.27 MB3 / CF-32 — the `open` attribute is now - // controlled from local state so the summary's - // `aria-expanded` stays in lock-step. The native disclosure - // semantics (Enter / Space toggle) are preserved. - className={cn( - "border-border/50 bg-muted/30 group rounded-md border", - "px-2.5 py-1.5 text-xs", - )} - > - - -
- {/* v1.12.0 — the source chips now live inside the - disclosure so they expand with the rest of the - grounding instead of always painting above the - answer. */} - {hasChips && metricSource && ( - - )} - {keyValues.length > 0 && ( -
    - {keyValues.map((kv, idx) => ( -
  • - {/* v1.4.25 W5 — `kv.label` (e.g. "avg7 systolic") - was rendered prefixed to every row, repeating - framing the disclosure heading already gives. - Drop the label and lead with the value; the - window stays as a parenthetical tail so the row - still answers "over what timeframe?". */} - - {kv.value} - {kv.unit ? ` ${kv.unit}` : ""} - - {kv.window && ( - - {" "} - ({kv.window}) - - )} -
  • - ))} -
- )} -
-
- )} - {/* v1.18.1 (Workstream C) — one-tap cadence-suggestion action - card. Live from the streaming hook, or restored from the - persisted message provenance on reload. Not shown on - in-flight or errored turns. */} - {!inProgress && - !errorCode && - (() => { - const sug = suggestion ?? metricSource?.suggestion ?? null; - return sug ? : null; - })()} - {/* v1.22 (W7/W6) — generalised confirm-card action. Live from the - streaming hook, or restored from persisted message provenance on - reload. Mirrors the reminder-suggestion block above. */} - {!inProgress && - !errorCode && - (() => { - const action = - suggestedAction ?? metricSource?.suggestedAction ?? null; - return action ? : null; - })()} - {/* v1.18.9 — quiet per-message token footer. The just-finished - streaming turn reads the `done.usage` envelope; a persisted / - reloaded turn reads the message's own `tokensUsed` + `model`. - Skipped on in-flight, errored, and refusal turns. */} - {!inProgress && !errorCode && providerType !== "refusal" && ( - - )} - {/* v1.22 — per-message hover action row. Icon-only, muted until the - bubble is hovered / focused on pointer devices; always visible on - touch (no hover to reveal it). Copy + Read-aloud + Good / Bad - feedback + Try-again, with the timestamp trailing. Only settled - persisted assistant turns get the row (skipped for refusals, - errors, in-flight stream bubbles). */} - {!inProgress && !errorCode && providerType !== "refusal" && content && ( -
- - {!streaming && } - {messageId && } - {!streaming && onRegenerate && ( - - )} - {createdAt && } -
- )} -
-
- ); -} - -/** - * v1.16.8 — per-message remember control under the user bubble. - * - * One tap stores the message text into the structured self-context via - * `POST /api/coach/about-me/adopt` (no `question` — the server matches - * the target field from the text itself: an allergy statement lands on - * the allergies field, a stated condition on conditions, everything - * else on the coach-focus slot). The stored text is visible and - * editable under Settings → AI and rides every future Coach system - * prompt. Settled states mirror `SelfContextAdoptOffer`: a short - * confirmation replaces the button after an adoption or a server-side - * dedupe; a failure surfaces a toast and the button stays tappable. - */ -function RememberUserMessage({ content }: { content: string }) { - const { t } = useTranslations(); - const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null); - // On settle the button unmounts while it holds focus, which would - // drop keyboard focus to . The confirmation paragraph takes - // the focus instead (`tabIndex={-1}` + programmatic focus) so the - // reading position survives the swap. - const statusRef = useRef(null); - useEffect(() => { - if (settled) statusRef.current?.focus(); - }, [settled]); - - const remember = useMutation({ - mutationFn: async () => { - return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", { - answer: content, - }); - }, - onSuccess: (data) => { - setSettled(data.adopted ? "adopted" : "duplicate"); - }, - onError: () => { - toast.error(t("insights.coach.rememberMessage.failed")); - }, - }); - - if (settled) { - return ( -

-

- ); - } - - return ( - - ); -} - -/** - * v1.16.1 — classic chat typing indicator: three dots pulsing in - * sequence inside the assistant bubble, shown only between submit and - * the first streamed token. Uses the stock `animate-pulse` keyframe - * with staggered delays so no custom keyframe is introduced; - * `motion-reduce` freezes the dots and the `label` stays as the - * screen-reader text either way. - * - * Exported since v1.16.5: the guided clarifying-question bubble replays - * the same indicator before a deterministic question reveals, so the - * scripted turns share one rhythm with the streamed ones. - */ -export function TypingDots({ label }: { label: string }) { - return ( - - {label} - - ); -} - -interface CoachMessageFeedbackProps { - messageId: string; -} - -function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) { - const { t } = useTranslations(); - const [submittedRating, setSubmittedRating] = useState< - "helpful" | "unhelpful" | null - >(null); - - const submit = useMutation({ - mutationFn: async (rating: "helpful" | "unhelpful") => { - try { - await apiPost(`/api/insights/chat/messages/${messageId}/feedback`, { - rating, - }); - } catch (err) { - // Treat 409 (already_rated) as a successful no-op so the user - // never sees an error toast for double-clicking the same chip. - if (!(err instanceof ApiError && err.status === 409)) { - throw err; - } - } - return rating; - }, - onSuccess: (rating) => setSubmittedRating(rating), - // v1.16.4 — a failed rating used to fail silently; the chips stayed - // tappable with no signal that nothing was recorded. - onError: () => { - toast.error(t("insights.coach.feedbackError")); - }, - }); - - // v1.22 — icon-only feedback in the per-message action row. After a rating - // lands, the chosen thumb stays visible in its confirming colour (the other - // is dropped) with an `sr-only` thanks so the signal is legible without a - // text caption breaking the icon row. - if (submittedRating) { - return ( - - {t("insights.coach.feedbackThanks")} - {submittedRating === "helpful" ? ( - - ); - } - - const helpfulLabel = t("insights.coach.feedbackHelpful"); - const unhelpfulLabel = t("insights.coach.feedbackUnhelpful"); - return ( -
- - -
- ); -} diff --git a/src/components/insights/coach-panel/read-aloud.tsx b/src/components/insights/coach-panel/read-aloud.tsx new file mode 100644 index 000000000..ac94adba6 --- /dev/null +++ b/src/components/insights/coach-panel/read-aloud.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { Volume2, VolumeX } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; + +/** + * Read-aloud + capability detection for the Coach message thread, split + * out of `message-thread.tsx` (v1.28.26 file-size split — pure code + * motion, no behaviour change). Owns the SSR-safe Speech-Synthesis / + * Clipboard feature detection, the natural-voice ranking, the + * `useReadAloud` hook, and the per-message read-aloud button, plus the + * shared icon-button styling the sibling action buttons reuse. + */ + +/** + * v1.22 (W5) — feature-detect the browser Speech Synthesis API SSR-safe. + * `useSyncExternalStore` returns false on the server + first client paint + * (so the read-aloud button is absent in markup) and resolves to the real + * capability after hydration, mirroring the mic button's detection pattern + * — no hydration mismatch warning, no `connect-src` (it runs in-browser). + */ +function useSpeechSynthesisSupported(): boolean { + return useSyncExternalStore( + () => () => {}, + () => + typeof window !== "undefined" && + "speechSynthesis" in window && + "SpeechSynthesisUtterance" in window, + () => false, + ); +} + +/** + * v1.22.1 — feature-detect the async Clipboard API SSR-safe, mirroring the + * Speech-Synthesis detection above. `navigator.clipboard` is `undefined` on + * plain-HTTP self-hosts (a supported insecure-context config), so the copy + * button must be absent there rather than rendering and error-toasting on tap. + * `useSyncExternalStore` returns false on the server + first client paint and + * resolves to the real capability after hydration — no hydration mismatch. + */ +export function useClipboardSupported(): boolean { + return useSyncExternalStore( + () => () => {}, + () => + typeof navigator !== "undefined" && + typeof navigator.clipboard?.writeText === "function", + () => false, + ); +} + +/** + * v1.22 — score a synthesis voice for a target language. Higher is better; + * a negative score marks a legacy/compact voice we would rather skip. Pure + * (no browser globals) so the ranking contract is unit-testable. Exported + * for the read-aloud voice-selection test. + * + * The default OS voice is often a legacy "compact" engine (Anna, Albert, + * Zarvox, eSpeak) that reads robotically. We prefer, in order: an Apple + * enhanced/premium or Siri voice, a Microsoft "Natural"/"Online" (Edge) + * voice, a "Google …" (Chrome) voice, and a small set of known-good names + * (Samantha). An exact lang-REGION match (de-DE/en-US) outweighs a bare + * language match. `localService` only breaks a tie. Known-bad names are + * penalised so they never win over a neutral remote voice. + */ +export function scoreSpeechVoice( + voice: { name: string; lang: string; localService?: boolean }, + lang: string, +): number { + const base = lang.split("-")[0].toLowerCase(); + const vlang = voice.lang.toLowerCase().replace("_", "-"); + if (!vlang.startsWith(base)) return -Infinity; + const name = voice.name.toLowerCase(); + let score = 0; + + // Exact lang-REGION match (de-DE, en-US) over a bare language match. + if (lang.includes("-") && vlang === lang.toLowerCase()) score += 40; + + // High-quality engines. + if (/\b(enhanced|premium)\b/.test(name)) score += 60; + if (name.includes("siri")) score += 55; + if (name.includes("natural") || name.includes("online")) score += 50; + if (name.startsWith("google")) score += 35; + if (name.includes("samantha")) score += 30; + + // Known-bad legacy / compact engines. + if (name.includes("compact")) score -= 80; + if (/\b(albert|zarvox|espeak|anna|fred|ralph|bahh|trinoids)\b/.test(name)) { + score -= 100; + } + + // Tie-breaker only: a local voice has no network latency. + if (voice.localService) score += 1; + + return score; +} + +/** + * v1.22 — pick the best-scoring installed voice for `lang`, or null when + * none beats a legacy/compact default (graceful fallback to the browser + * default). Pure helper, exported for the ranking test. + */ +export function pickSpeechVoice( + voices: SpeechSynthesisVoice[], + lang: string, +): SpeechSynthesisVoice | null { + let best: SpeechSynthesisVoice | null = null; + let bestScore = 0; // require a positive score to override the default + for (const v of voices) { + const s = scoreSpeechVoice(v, lang); + if (s > bestScore) { + bestScore = s; + best = v; + } + } + return best; +} + +/** + * v1.22 (W5) — read the assistant prose aloud via the browser's + * `SpeechSynthesis`. Client-only, no dependency, no egress, CSP-clean. A + * single in-flight utterance: a new `speak` cancels any prior one; `toggle` + * stops while speaking. + * + * v1.22 — natural-voice selection. The browser default is frequently a + * legacy/compact engine that reads robotically; instead of only stamping + * `utterance.lang` we enumerate `getVoices()`, rank the candidates for the + * locale (`pickSpeechVoice`), and assign the winner. The voice list is async + * (empty on the first call, and on iOS Safari it can stay empty until the + * first user-gesture speak), so we subscribe to `voiceschanged` and also + * re-read it lazily on the first toggle. The chosen voice is cached per + * locale in a ref so the ranking runs once. + */ +function useReadAloud(): { + supported: boolean; + speaking: boolean; + toggle: (text: string, lang: string) => void; +} { + const supported = useSpeechSynthesisSupported(); + const [speaking, setSpeaking] = useState(false); + // Cache the resolved voice per locale so the ranking runs once. + const voiceByLocaleRef = useRef>( + new Map(), + ); + + // Pre-warm the voice list + keep it fresh: the list arrives asynchronously + // and `voiceschanged` fires when the OS finishes loading installed voices. + useEffect(() => { + if (!supported) return; + const synth = window.speechSynthesis; + const refresh = () => { + // A late-arriving list can surface a better voice than an early read; + // clear the cache so the next toggle re-ranks against the full set. + voiceByLocaleRef.current.clear(); + }; + synth.getVoices(); + synth.addEventListener?.("voiceschanged", refresh); + return () => synth.removeEventListener?.("voiceschanged", refresh); + }, [supported]); + + // Stop any in-flight utterance if the bubble unmounts (thread reset, nav). + useEffect(() => { + return () => { + if (typeof window !== "undefined" && "speechSynthesis" in window) { + window.speechSynthesis.cancel(); + } + }; + }, []); + + const resolveVoice = useCallback( + (synth: SpeechSynthesis, lang: string): SpeechSynthesisVoice | null => { + const cached = voiceByLocaleRef.current.get(lang); + if (cached !== undefined) return cached; + const voices = synth.getVoices(); + // An empty list (first call / iOS pre-gesture) is not cached so a later + // toggle re-ranks once the OS has populated the voices. + if (voices.length === 0) return null; + const chosen = pickSpeechVoice(voices, lang); + voiceByLocaleRef.current.set(lang, chosen); + return chosen; + }, + [], + ); + + const toggle = useCallback( + (text: string, lang: string) => { + if (!supported) return; + const synth = window.speechSynthesis; + if (speaking) { + synth.cancel(); + setSpeaking(false); + return; + } + const trimmed = text.trim(); + if (!trimmed) return; + synth.cancel(); + const utterance = new SpeechSynthesisUtterance(trimmed); + const voice = resolveVoice(synth, lang); + if (voice) { + utterance.voice = voice; + utterance.lang = voice.lang; + } else { + utterance.lang = lang; + } + // A natural cadence: default rate, neutral pitch. + utterance.rate = 1.0; + utterance.pitch = 1.0; + utterance.onend = () => setSpeaking(false); + utterance.onerror = () => setSpeaking(false); + setSpeaking(true); + synth.speak(utterance); + }, + [supported, speaking, resolveVoice], + ); + + return { supported, speaking, toggle }; +} + +/** + * v1.22 — shared styling for the icon-only per-message action buttons. One + * tap convention across Copy / Read-aloud / feedback / Try-again: a 44px + * mobile tap floor (WCAG 2.5.5) collapsing to a compact 32px desktop target, + * muted until hover/focus, with the standard focus ring. + */ +export const COACH_ICON_BUTTON = cn( + "text-muted-foreground hover:text-foreground focus-visible:ring-ring/50", + "inline-flex size-11 min-w-11 shrink-0 items-center justify-center rounded", + "outline-none focus-visible:ring-2 disabled:opacity-50 sm:size-8 sm:min-w-8", +); + +/** + * v1.22 (W5) — read-aloud toggle for a settled assistant turn. Hidden when + * the browser has no Speech Synthesis support. Speaks `stripChartTokens` + * (the same text the prose shows), so stray tokens are never voiced. + */ +export function ReadAloudButton({ content }: { content: string }) { + const { t, locale } = useTranslations(); + const { supported, speaking, toggle } = useReadAloud(); + if (!supported) return null; + const label = speaking + ? t("insights.coach.readAloudStop") + : t("insights.coach.readAloud"); + return ( + + ); +} diff --git a/src/lib/ai/coach/snapshot-blocks/compliance-block.ts b/src/lib/ai/coach/snapshot-blocks/compliance-block.ts new file mode 100644 index 000000000..763822efc --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/compliance-block.ts @@ -0,0 +1,199 @@ +/** + * Medication-compliance block for the Coach snapshot. + * + * Medication compliance lives outside the structured features — the + * legacy Coach surface labelled it as "general" provenance only. + * v1.4.20.1 shipped a per-day adherence row; v1.16.9 derives it from + * the band-engine LEDGER (the same expansion the compliance % and the + * dose-history view consume): a slot counts against the rate only once + * it is genuinely missed, a pending/upcoming slot never reads as "not + * taken", deliberate skips and ad-hoc takes stay out of the + * denominator, and cross-source duplicate rows collapse onto one slot. + * + * Split out of `snapshot.ts`; the builder passes the medication rows it + * already read plus the shared accumulators, so the emitted shape and + * ordering are unchanged. + */ +import { + buildComplianceLedgerRows, + buildComplianceMedicationContext, + calculateCompliance, + lastNonSkippedTakenAt, + type ComplianceSchedule, + type IntakeEvent, +} from "@/lib/analytics/compliance"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; +import { annotate } from "@/lib/logging/context"; +import { isoWeekKey, tzDayKey, tzWeekday } from "../snapshot-series"; +import type { + CoachProvenance, + CoachProvenanceMetric, + CoachScopeSource, +} from "../types"; + +/** + * The medication row shape the block consumes — exactly what the + * builder's Prisma read selects, expressed through the compliance + * engine's own parameter types so the two can never drift. + */ +type ComplianceBlockMedication = Parameters< + typeof buildComplianceMedicationContext +>[0] & { + schedules: ComplianceSchedule[]; + intakeEvents: IntakeEvent[]; +}; + +interface ComplianceBlockContext { + complianceMeds: ComplianceBlockMedication[]; + userTz: string; + cutoff: Date; + recentCutoff: Date; + now: Date; + snapshot: Record; + metrics: Set; + counts: NonNullable; + registerBlock: (key: string, source: CoachScopeSource) => void; +} + +export function buildComplianceBlock( + ctx: Readonly, +): void { + const { + complianceMeds, + userTz, + cutoff, + recentCutoff, + now, + snapshot, + metrics, + counts, + registerBlock, + } = ctx; + const ledgerRows: DoseHistoryRow[] = []; + // v1.17 W1c — the coach's headline adherence figure routes through the + // SAME `calculateCompliance(...).rate` ledger authority the medication + // card shows (the ledger path, `medicationContext` supplied), so the + // coach can never quote a denominator the card doesn't use. Per + // medication we take the ledger numerator (on-time + late takes) and + // denominator (taken + missed) and pool them across the user's + // scheduled medications: for a single medication the headline equals + // that med's card rate exactly; for several it is the dose-weighted + // overall adherence (the same pooling the cross-med timeline below + // already uses), never a per-day / per-week denominator of its own. + let complianceTaken = 0; + let complianceDenominator = 0; + const windowDaysForRate = Math.max( + 1, + Math.round((now.getTime() - cutoff.getTime()) / (24 * 60 * 60 * 1000)), + ); + for (const med of complianceMeds) { + if (med.schedules.length === 0) continue; + const medCtx = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(med.intakeEvents), + userTz, + ); + ledgerRows.push( + ...buildComplianceLedgerRows( + med.intakeEvents, + med.schedules, + medCtx, + cutoff, + now, + now, + ), + ); + // The card's rate IS `calculateCompliance(...).rate` over the ledger; + // aggregate the same taken / (taken + missed) counts here so the + // coach's single headline % equals what the card renders. + const result = calculateCompliance( + med.intakeEvents, + med.schedules, + windowDaysForRate, + med.createdAt, + { now, medicationContext: medCtx }, + ); + complianceTaken += result.taken; + complianceDenominator += result.taken + result.missed; + } + // Countable rows: taken (on-time or late) or genuinely missed. The + // pending / upcoming / skipped / ad-hoc rows carry no adherence signal. + const countable = ledgerRows.filter( + (r) => + r.status === "taken_on_time" || + r.status === "taken_late" || + r.status === "missed", + ); + if (countable.length > 0) { + const recent = countable.filter((r) => r.at >= recentCutoff); + const olderRows = countable.filter((r) => r.at < recentCutoff); + const isTaken = (r: DoseHistoryRow) => + r.status === "taken_on_time" || r.status === "taken_late"; + const recentByDay = new Map< + string, + { date: Date; total: number; taken: number } + >(); + for (const r of recent) { + const key = tzDayKey(r.at, userTz); + const e = recentByDay.get(key) ?? { + date: r.at, + total: 0, + taken: 0, + }; + e.total += 1; + if (isTaken(r)) e.taken += 1; + recentByDay.set(key, e); + } + const recentRows = Array.from(recentByDay.entries()) + .map(([date, info]) => ({ + date, + weekday: tzWeekday(info.date, userTz), + rate: Math.round((info.taken / info.total) * 100) / 100, + taken: info.taken, + total: info.total, + })) + .sort((a, b) => a.date.localeCompare(b.date)); + const olderByWeek = new Map(); + for (const r of olderRows) { + const key = isoWeekKey(r.at, userTz); + const e = olderByWeek.get(key) ?? { taken: 0, total: 0 }; + e.total += 1; + if (isTaken(r)) e.taken += 1; + olderByWeek.set(key, e); + } + const weeklyRows = Array.from(olderByWeek.entries()) + .map(([weekISO, v]) => ({ + weekISO, + rate: v.total > 0 ? Math.round((v.taken / v.total) * 100) / 100 : 0, + taken: v.taken, + total: v.total, + })) + .sort((a, b) => a.weekISO.localeCompare(b.weekISO)); + snapshot.compliance = { + // v1.17 W1c — headline adherence % from the SAME ledger authority + // (`calculateCompliance(...).rate`) the medication card shows: the + // dose-weighted pool of taken / (taken + missed) across the user's + // scheduled medications, so the coach quotes the card's figure + // (single med) or its honest overall adherence (several meds) rather + // than a per-day / per-week rate built off a different denominator. + // Integer 0-100 to match the card's rounding; null when no scheduled + // medication has any countable dose in the window. + rate: + complianceDenominator > 0 + ? Math.round((complianceTaken / complianceDenominator) * 100) + : null, + timeline: { recent: recentRows, weekly: weeklyRows }, + }; + metrics.add("compliance"); + counts.compliance = countable.length; + registerBlock("compliance", "compliance"); + } else { + // v1.7.0 — toggled-on cluster with no rows. Annotate so the + // dashboards can distinguish "user has no medication data" from + // "medication cluster was off". + annotate({ + action: { name: "coach.cluster.empty_skipped" }, + meta: { cluster: "medication" }, + }); + } +} diff --git a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts new file mode 100644 index 000000000..7f0e855e1 --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts @@ -0,0 +1,204 @@ +/** + * Glucose block for the Coach snapshot (v1.7.0, per-context daily means). + * + * Glucose is summarised per `GlucoseContext` so the Coach can tell + * fasting from postprandial without seeing raw samples. Each context + * gets its own per-day mean timeline; the block omits when no rows. + * The clinical panel summary (TIR / GMI / CV%) is computed over the + * fixed trailing-30-day rows the insights panel + doctor report use, + * never the coach's variable narration window. + * + * Split out of `snapshot.ts`; the builder passes the rows it already + * read plus the shared accumulators, so the emitted shape and ordering + * are unchanged. + */ +import { convertGlucose, type GlucoseUnit } from "@/lib/glucose"; +import { + computeGlucoseClinicalMetrics, + GLUCOSE_PANEL_WINDOW_DAYS, +} from "@/lib/analytics/glucose-metrics"; +import { annotate } from "@/lib/logging/context"; +import type { ReferenceMetric } from "@/lib/reference-ranges"; +import { bucketWeekly, buildDailyValueRows } from "../snapshot-series"; +import type { + CoachProvenance, + CoachProvenanceMetric, + CoachScopeSource, +} from "../types"; + +/** + * v1.17.0 — the glucose clinical panel is a fixed trailing-30-day artifact, + * identical across the insights panel, the dashboard snapshot, the doctor + * report, and (here) the coach. Pinned independently of the coach's variable + * narration window so the coach's TIR/GMI/CV% always equals what the panel + * renders. + */ +export const GLUCOSE_CLINICAL_WINDOW_DAYS = GLUCOSE_PANEL_WINDOW_DAYS; + +interface GlucoseBlockContext { + measurementRows: ReadonlyArray<{ + type: string; + value: number; + measuredAt: Date; + glucoseContext: unknown; + }>; + /** Effective window cutoff for the glucose source (multi-cluster aware). */ + glucoseCutoff: Date; + glucoseClinicalRows: Array<{ value: number; measuredAt: Date }> | null; + glucoseUnit: GlucoseUnit; + recentCutoff: Date; + userTz: string; + now: Date; + snapshot: Record; + metrics: Set; + counts: NonNullable; + registerBlock: (key: string, source: CoachScopeSource) => void; + groundingValues: Map; +} + +export function buildGlucoseBlock(ctx: Readonly): void { + const { + measurementRows, + glucoseCutoff, + glucoseClinicalRows, + glucoseUnit, + recentCutoff, + userTz, + now, + snapshot, + metrics, + counts, + registerBlock, + groundingValues, + } = ctx; + const glucoseRows = measurementRows.filter( + (r) => r.type === "BLOOD_GLUCOSE" && r.measuredAt >= glucoseCutoff, + ); + if (glucoseRows.length === 0) { + annotate({ + action: { name: "coach.cluster.empty_skipped" }, + meta: { cluster: "glucose", source: "glucose" }, + }); + } else { + // Group by context (NULL → "unspecified"), then per-day mean. + const byContext = new Map< + string, + Array<{ measuredAt: Date; value: number }> + >(); + for (const r of glucoseRows) { + const ctxKey = r.glucoseContext + ? String(r.glucoseContext).toLowerCase() + : "unspecified"; + const list = byContext.get(ctxKey) ?? []; + list.push({ measuredAt: r.measuredAt, value: r.value }); + byContext.set(ctxKey, list); + } + const contexts: Record = {}; + for (const [ctxKey, rows] of byContext) { + // v1.16.16 — glucose is stored canonical mg/dL. A mmol/L-preference + // user's Coach must read the same number every other surface shows + // (5.5, not 100). Aggregate the per-day / weekly means in raw mg/dL, + // then convert each resulting figure ONCE (parity with the series + // DTO + detail page + FHIR). mg/dL users stay byte-identical because + // the conversion is skipped entirely. + const recent = buildDailyValueRows(rows, recentCutoff, userTz).map((d) => + glucoseUnit === "mmol/L" + ? { ...d, value: convertGlucose(d.value, glucoseUnit) } + : d, + ); + const weekly = bucketWeekly( + rows.filter((r) => r.measuredAt < recentCutoff), + userTz, + ).map((w) => + glucoseUnit === "mmol/L" + ? { ...w, mean: convertGlucose(w.mean, glucoseUnit) } + : w, + ); + contexts[ctxKey] = { recent, weekly }; + } + // v1.17.0 — clinical panel summary from the ONE literature-locked engine + // the insights panel + doctor report also consume, computed over the SAME + // fixed trailing-30-day window + rows the panel uses (`glucoseClinicalRows`, + // not the coach-window / cap-trimmed `glucoseRows`), so the coach can never + // quote a TIR / GMI / CV% figure the panel doesn't show — true numeric + // parity, independent of the user's coach scope. Gated by `stillLearning` + // so a thin spot-data window is offered as a calm "still learning" note + // rather than asserted as a clinical AGP. The headline mean is converted + // ONCE to the user's display unit; the unit-agnostic fractions / indices + // travel as-is. + const clinicalRaw = computeGlucoseClinicalMetrics( + (glucoseClinicalRows ?? []).map((r) => ({ + measuredAt: r.measuredAt, + mgdl: r.value, + })), + { windowDays: GLUCOSE_CLINICAL_WINDOW_DAYS, now }, + ); + const clinical = clinicalRaw.stillLearning + ? { + stillLearning: true as const, + reason: clinicalRaw.stillLearningReason, + readingCount: clinicalRaw.readingCount, + spanDays: Math.round(clinicalRaw.actualSpanDays), + } + : { + stillLearning: false as const, + windowDays: clinicalRaw.windowDays, + spanDays: Math.round(clinicalRaw.actualSpanDays), + readingCount: clinicalRaw.readingCount, + meanInRange: + clinicalRaw.meanMgdl !== null + ? Math.round( + convertGlucose(clinicalRaw.meanMgdl, glucoseUnit) * + (glucoseUnit === "mmol/L" ? 10 : 1), + ) / (glucoseUnit === "mmol/L" ? 10 : 1) + : null, + tirPercent: clinicalRaw.distribution + ? Math.round(clinicalRaw.distribution.tir * 100) + : null, + timeBelowPercent: clinicalRaw.distribution + ? Math.round(clinicalRaw.distribution.tbrLevel1 * 100) + : null, + timeAbovePercent: clinicalRaw.distribution + ? Math.round(clinicalRaw.distribution.tarLevel1 * 100) + : null, + gmi: + clinicalRaw.gmi !== null + ? Math.round(clinicalRaw.gmi * 10) / 10 + : null, + estimatedA1c: + clinicalRaw.estimatedA1c !== null + ? Math.round(clinicalRaw.estimatedA1c * 10) / 10 + : null, + cvPercent: clinicalRaw.variability + ? Math.round(clinicalRaw.variability.cv) + : null, + unstable: clinicalRaw.variability?.unstable ?? null, + // Density-derived: a sparse spot series stays a spot-reading + // estimate, a continuous CGM stream (Nightscout) reads false so the + // model can narrate the TIR/GMI as continuous-trace figures. + isSpotEstimate: clinicalRaw.isSpotEstimate, + }; + // The display unit travels with the block so the prompt renders + // " " and the EVIDENCE BLOCK tags glucose lines correctly. + snapshot.glucose = { unit: glucoseUnit, byContext: contexts, clinical }; + metrics.add("glucose"); + counts.glucose = glucoseRows.length; + registerBlock("glucose", "glucose"); + // W7 grounding: fasting glucose mean in RAW mg/dL (the reference band's + // unit), independent of the user's mmol/L display preference. The + // grounding line's band selection respects the W6 `hasDiabetes` opt-in; + // here we only feed the representative fasting value. Fall back to the + // overall mean when no row is tagged FASTING so the band is still cited. + const fastingRows = glucoseRows.filter( + (r) => String(r.glucoseContext).toUpperCase() === "FASTING", + ); + const glucoseScalarRows = + fastingRows.length > 0 ? fastingRows : glucoseRows; + const glucoseMeanMgdl = + glucoseScalarRows.reduce((s, r) => s + r.value, 0) / + glucoseScalarRows.length; + if (Number.isFinite(glucoseMeanMgdl)) { + groundingValues.set("BLOOD_GLUCOSE", glucoseMeanMgdl); + } + } +} diff --git a/src/lib/ai/coach/snapshot-blocks/sleep-block.ts b/src/lib/ai/coach/snapshot-blocks/sleep-block.ts new file mode 100644 index 000000000..199ec03cd --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/sleep-block.ts @@ -0,0 +1,213 @@ +/** + * Sleep blocks for the Coach snapshot. + * + * Two sections, both gated on the `sleep` source: + * - the v1.7.0 per-night timeline (with optional per-stage enrichment), + * reconstructed through the shared sleep-night helper so the Coach + * narrates the same nightly numbers every other sleep surface shows; + * - the v1.17.0 sleep-rhythm summary (sleep-debt + chronotype), built + * from the SAME assembler the dashboard route uses over the rhythm's + * own fixed trailing-42-day rows. + * + * Split out of `snapshot.ts`; the builder passes the rows it already + * read plus the shared accumulators, so the emitted shape and ordering + * are unchanged. + */ +import { + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; +import { + reconstructNights, + sleepNeedMinutes, +} from "@/lib/insights/derived/sleep-score"; +import { + computeSleepRhythmFromNights, + type SleepRhythmDto, +} from "@/lib/insights/derived/sleep-rhythm"; +import { annotate } from "@/lib/logging/context"; +import type { ReferenceMetric } from "@/lib/reference-ranges"; +import { bucketWeekly, tzWeekday } from "../snapshot-series"; +import type { + CoachProvenance, + CoachProvenanceMetric, + CoachScopeSource, +} from "../types"; + +interface SleepTimelineBlockContext { + sleepRows: SleepStageRow[]; + sourcePriorityJson: unknown; + userTz: string; + recentCutoff: Date; + snapshot: Record; + metrics: Set; + counts: NonNullable; + registerBlock: (key: string, source: CoachScopeSource) => void; + groundingValues: Map; +} + +/** + * ── v1.7.0 sleep block (with optional per-stage enrichment) ─────── + * + * Sleep needs the `sleepStage` column so the Coach can narrate REM / + * core / deep / awake minutes per night instead of a flat duration. + * The shared `byType("SLEEP_DURATION")` rows already cover the + * duration timeline, but they drop the stage label — so the sleep + * branch builds its own block. Per-night stage minutes come from a + * dedicated read of the SLEEP_DURATION rows that carry a non-null + * stage; the duration timeline is built from the same rows summed per + * night (one night = the sum of its per-stage rows). + */ +export function buildSleepTimelineBlock( + ctx: Readonly, +): void { + const { + sleepRows, + sourcePriorityJson, + userTz, + recentCutoff, + snapshot, + metrics, + counts, + registerBlock, + groundingValues, + } = ctx; + // The SLEEP_DURATION rows (with the `sleepStage` column) are read in + // parallel by the builder. + if (sleepRows.length === 0) { + annotate({ + action: { name: "coach.cluster.empty_skipped" }, + meta: { cluster: "sleep", source: "sleep" }, + }); + } else { + // v1.11.5 — reconstruct per-night TIME-ASLEEP totals through the shared + // helper so the Coach narrates the same nightly numbers every other + // sleep surface shows: stages clustered into sessions, a dual-source + // night collapsed to one canonical source, and the granular + // CORE/DEEP/REM partition counted WITHOUT double-counting the bare + // ASLEEP aggregate Apple Health writes alongside it. IN_BED + AWAKE are + // excluded from the asleep total. + const nights = reconstructSleepNights( + sleepRows, + userTz, + sourcePriorityJson, + ).filter((n) => n.asleepMinutes > 0); + // Recent nights: asleep duration + stage breakdown when present. + const recentNights = nights + .filter((n) => n.measuredAt >= recentCutoff) + .map((n) => { + const row: Record = { + date: n.measuredAt, + weekday: tzWeekday(n.measuredAt, userTz), + minutes: Math.round(n.asleepMinutes), + }; + const stageEntries = Object.entries(n.stages).filter( + ([stage]) => stage !== "IN_BED" && stage !== "AWAKE", + ); + if (stageEntries.length > 0) { + row.stages = Object.fromEntries( + stageEntries.map(([k, v]) => [ + k.toLowerCase(), + Math.round(v as number), + ]), + ); + } + return row; + }) + .sort((a, b) => String(a.date).localeCompare(String(b.date))); + const olderNights = nights + .filter((n) => n.measuredAt < recentCutoff) + .map((n) => ({ measuredAt: n.measuredAt, value: n.asleepMinutes })); + snapshot.sleep = { + timeline: { + recent: recentNights, + weekly: bucketWeekly(olderNights, userTz), + }, + }; + metrics.add("sleep"); + counts.sleep = sleepRows.length; + registerBlock("sleep", "sleep"); + // W7 grounding: recent nightly asleep duration in HOURS against the + // AASM 7–9 h band (the reference unit is hours; the snapshot stores + // minutes). Mean over the recent nights the block already reconstructed. + if (recentNights.length > 0) { + const meanMin = + recentNights.reduce( + (s, n) => s + (typeof n.minutes === "number" ? n.minutes : 0), + 0, + ) / recentNights.length; + if (meanMin > 0) { + groundingValues.set("SLEEP_DURATION", meanMin / 60); + } + } + } +} + +interface SleepRhythmBlockContext { + sleepRhythmRows: SleepStageRow[]; + sourcePriorityJson: unknown; + userTz: string; + ageYears: number | null; + snapshot: Record; + metrics: Set; + registerBlock: (key: string, source: CoachScopeSource) => void; +} + +/** + * ── v1.17.0 sleep-rhythm block (sleep-debt + chronotype) ────────── + * + * The two server-authoritative timing signals the Sleep page + the + * dashboard summary render — cumulative sleep debt and the MCTQ + * chronotype band + social jetlag. Built from the SAME assembler the + * dashboard route uses (`reconstructNights` → `computeSleepRhythmFromNights`), + * over the rhythm's OWN fixed trailing-42-day rows, so the coach quotes the + * exact debt + band the page shows regardless of the coach's narration + * window. ONE ENGINE: this never recomputes sleep-debt or chronotype inline — + * the math lives in `sleep-debt.ts` / `chronotype.ts`, reached through the + * assembler. The `needMinutes` is the SAME age-resolved need the derived + * block + Sleep Score read (`sleepNeedMinutes(ageYears)`), and the + * `sourcePriorityJson` is the one already loaded for the per-stage sleep + * block — no extra read beyond the rhythm rows. + * + * LEARNING-GATE HONESTY: both signals carry a calm `partial` / `learning` + * state below their night thresholds. The chronotype `band` is emitted ONLY + * when the state is `ready` — a learning chronotype is surfaced as + * "still calibrating", never asserted as a band the data can't support. + */ +export function buildSleepRhythmBlock( + ctx: Readonly, +): void { + const { + sleepRhythmRows, + sourcePriorityJson, + userTz, + ageYears, + snapshot, + metrics, + registerBlock, + } = ctx; + const rhythm: SleepRhythmDto = computeSleepRhythmFromNights( + reconstructNights(sleepRhythmRows, userTz, sourcePriorityJson), + sleepNeedMinutes(ageYears), + ); + const chronotypeReady = rhythm.chronotype.state === "ready"; + snapshot.sleepRhythm = { + sleepDebt: { + state: rhythm.sleepDebt.state, + debtMinutes: rhythm.sleepDebt.debtMinutes, + needMinutes: rhythm.sleepDebt.needMinutes, + }, + chronotype: { + state: rhythm.chronotype.state, + // Only assert a band + social jetlag once the chronotype is `ready`. + // A `learning` chronotype carries no band the data supports — the + // model treats it as "still calibrating", never a typed assertion. + band: chronotypeReady ? rhythm.chronotype.band : null, + socialJetlagMinutes: chronotypeReady + ? rhythm.chronotype.socialJetlagMinutes + : null, + }, + }; + metrics.add("sleep"); + registerBlock("sleepRhythm", "sleep"); +} diff --git a/src/lib/ai/coach/snapshot-blocks/workouts-block.ts b/src/lib/ai/coach/snapshot-blocks/workouts-block.ts new file mode 100644 index 000000000..39429ddb1 --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/workouts-block.ts @@ -0,0 +1,98 @@ +/** + * Workouts block for the Coach snapshot (v1.7.0). + * + * The Workout model is never dumped row-for-row. The block carries + * the most recent `WORKOUT_RECENT_CAP` sessions (sport, duration, + * energy, distance, avg/max HR) plus a per-sport weekly count + total + * duration/energy rollup for the tail so the prompt stays bounded + * even for a heavy-training account at a long window. + * + * Split out of `snapshot.ts`; the builder passes the rows it already + * read plus the shared accumulators, so the emitted shape and ordering + * are unchanged. + */ +import { annotate } from "@/lib/logging/context"; +import { tzDayKey, tzWeekday } from "../snapshot-series"; +import type { + CoachProvenance, + CoachProvenanceMetric, + CoachScopeSource, +} from "../types"; + +/** + * v1.7.0 — the workouts block never dumps every session. It carries + * the most-recent N sessions verbatim plus a per-sport rollup for the + * tail, so a heavy-training account at a long window stays bounded. + */ +const WORKOUT_RECENT_CAP = 15; + +interface WorkoutsBlockContext { + workoutRows: Array<{ + sportType: string; + startedAt: Date; + durationSec: number; + totalEnergyKcal: number | null; + totalDistanceM: number | null; + avgHeartRate: number | null; + maxHeartRate: number | null; + }>; + userTz: string; + snapshot: Record; + metrics: Set; + counts: NonNullable; + registerBlock: (key: string, source: CoachScopeSource) => void; +} + +export function buildWorkoutsBlock(ctx: Readonly): void { + const { workoutRows, userTz, snapshot, metrics, counts, registerBlock } = ctx; + // The workout sessions are read in parallel by the builder. + if (workoutRows.length === 0) { + annotate({ + action: { name: "coach.cluster.empty_skipped" }, + meta: { cluster: "workouts", source: "workouts" }, + }); + } else { + const recentList = workoutRows.slice(0, WORKOUT_RECENT_CAP).map((w) => ({ + date: tzDayKey(w.startedAt, userTz), + weekday: tzWeekday(w.startedAt, userTz), + sport: w.sportType, + durationMin: Math.round(w.durationSec / 60), + energyKcal: w.totalEnergyKcal ?? null, + distanceM: w.totalDistanceM ?? null, + avgHr: w.avgHeartRate ?? null, + maxHr: w.maxHeartRate ?? null, + })); + // Per-sport rollup over the whole window. + const bySport = new Map< + string, + { count: number; durationMin: number; energyKcal: number } + >(); + for (const w of workoutRows) { + const e = bySport.get(w.sportType) ?? { + count: 0, + durationMin: 0, + energyKcal: 0, + }; + e.count += 1; + e.durationMin += Math.round(w.durationSec / 60); + e.energyKcal += w.totalEnergyKcal ?? 0; + bySport.set(w.sportType, e); + } + const sportRollup = Array.from(bySport.entries()) + .map(([sport, v]) => ({ + sport, + count: v.count, + totalDurationMin: v.durationMin, + totalEnergyKcal: Math.round(v.energyKcal), + })) + .sort((a, b) => b.count - a.count); + snapshot.workouts = { + recent: recentList, + perSport: sportRollup, + totalInWindow: workoutRows.length, + }; + metrics.add("workouts"); + counts.workouts = workoutRows.length; + registerBlock("workouts", "workouts"); + } +} diff --git a/src/lib/ai/coach/snapshot-cache.ts b/src/lib/ai/coach/snapshot-cache.ts new file mode 100644 index 000000000..ccf534b99 --- /dev/null +++ b/src/lib/ai/coach/snapshot-cache.ts @@ -0,0 +1,88 @@ +/** + * In-memory result cache for the Coach snapshot builder. + * + * v1.4.33 — 60-second in-memory cache for `buildCoachSnapshot()`. The + * snapshot reads only persisted data; a single chat conversation sends + * 2-4 turns within a minute and the snapshot would otherwise rebuild + * from the same rows each turn. Caching the result for 60s shaves the + * ~10 measurement reads + the GLP-1 / mood / intake side-fetches off + * every turn after the first, which `.planning/round-v1433-audit-perf.md` + * §3.3 estimates at 200-800 ms of server-side tail. + * + * Scope is part of the cache key so a switch from `last30days` to + * `last7days` (or a different `sources` set) computes fresh. The map + * is bounded at 64 entries — a multi-tenant deployment with a few + * active power users sits well inside that ceiling even if each cycles + * through several scopes per minute. + * + * Split out of `snapshot.ts` so the builder file carries only the + * assembly logic; the public surface (`buildCoachSnapshot`, + * `__resetCoachSnapshotCacheForTests`) stays importable from + * `./snapshot` via re-export. + */ +import type { CoachSnapshotResult } from "./snapshot"; +import type { CoachScope, CoachScopeWindow } from "./types"; + +/** Default window when the caller doesn't pass a scope. */ +export const DEFAULT_WINDOW: CoachScopeWindow = "last30days"; + +const SNAPSHOT_TTL_MS = 60_000; +const SNAPSHOT_LRU_MAX = 64; +const snapshotCache = new Map< + string, + { expiresAt: number; result: CoachSnapshotResult } +>(); + +export function snapshotCacheKey( + userId: string, + scope: CoachScope | undefined, +): string { + // v1.7.0 — when the request pins an explicit source list, key on it. + // Otherwise the source set is derived from the user's saved + // `dataClusters`, which we don't read here (the cache must stay + // I/O-free on a hit) — key on a stable `clusters` marker instead. + // A cluster change is reflected on the next cache miss (≤60 s), the + // same staleness window every other pref change already tolerates. + const window = scope?.window ?? DEFAULT_WINDOW; + const sourceList = + scope?.sources && scope.sources.length > 0 + ? Array.from(scope.sources).sort().join(",") + : "clusters"; + return `${userId}|${window}|${sourceList}`; +} + +export function readSnapshotCache(key: string): CoachSnapshotResult | null { + const entry = snapshotCache.get(key); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + snapshotCache.delete(key); + return null; + } + // Touch for LRU — re-insert moves to the end of the Map's iteration order. + snapshotCache.delete(key); + snapshotCache.set(key, entry); + return entry.result; +} + +export function writeSnapshotCache( + key: string, + result: CoachSnapshotResult, +): void { + if (snapshotCache.size >= SNAPSHOT_LRU_MAX) { + // Evict the oldest entry — JS Map iteration order is insertion order, + // so the first key is the least-recently inserted/touched. + const oldest = snapshotCache.keys().next().value; + if (oldest !== undefined) { + snapshotCache.delete(oldest); + } + } + snapshotCache.set(key, { + expiresAt: Date.now() + SNAPSHOT_TTL_MS, + result, + }); +} + +/** Clear the snapshot cache. Test-only escape hatch. */ +export function __resetCoachSnapshotCacheForTests(): void { + snapshotCache.clear(); +} diff --git a/src/lib/ai/coach/snapshot-series.ts b/src/lib/ai/coach/snapshot-series.ts new file mode 100644 index 000000000..636b9f5bc --- /dev/null +++ b/src/lib/ai/coach/snapshot-series.ts @@ -0,0 +1,339 @@ +/** + * Pure time / series helpers for the Coach snapshot builder. + * + * Everything here is deterministic over its inputs — window arithmetic, + * user-timezone day/week bucketing, daily/weekly fold-downs, the coarse + * rollup tail, and the scope resolver. Split out of `snapshot.ts` so the + * builder file carries only the assembly logic; the dependency direction + * is one-way (snapshot → series), never back. + */ +import { userDayKey } from "@/lib/tz/format"; +import { buildTieredSeries } from "@/lib/rollups/tiered-context"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { buildBaselineBand } from "@/lib/insights/derived/baseline"; +import { DEFAULT_WINDOW } from "./snapshot-cache"; +import type { CoachScope, CoachScopeSource, CoachScopeWindow } from "./types"; + +const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; + +export function windowToDays(window: CoachScopeWindow): number { + switch (window) { + case "last7days": + return 7; + case "last30days": + return 30; + case "last90days": + return 90; + case "lastYear": + // v1.4.27 B7 / BL-P6-4 — year-in-review window. Same 365-day + // ceiling as the historical `allTime` cap so the prompt budget + // stays bounded, but with explicit semantics — the Coach knows + // the user is asking about a long-horizon view rather than the + // unbounded "all time" backfill. + return 365; + case "allTime": + // Cap "allTime" at one year for the timeline. The aggregate + // section already cites multi-year ranges via the features + // pipeline; the timeline stays tight to keep token budget sane. + return 365; + } +} + +/** + * YYYY-MM-DD day key anchored to the user's display timezone. The + * prompt asks the Coach "did you read at 23:50 last night?" — that + * "last night" answer has to use the user's clock, not UTC. Up to + * v1.4.24 the day-key was UTC, so a 23:50 Pacific/Auckland reading + * landed in the next UTC day's bucket and the Coach couldn't pair it + * with the user's mental model. Delegates to the canonical + * `userDayKey` so every day-bucket surface stays byte-aligned. + */ +export function tzDayKey(date: Date, tz: string): string { + return userDayKey(date, tz); +} + +/** + * 0..6 → "Sun".."Sat" using the user's tz so "last Monday" in the + * prompt agrees with the calendar the user is looking at. + */ +export function tzWeekday(date: Date, tz: string): string { + const wk = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + weekday: "short", + }).format(date); + // Normalise "Mon" / "Mon," — modern engines drop the comma but + // older ones may include it. + const canon = wk.slice(0, 3) as (typeof WEEKDAY_KEYS)[number]; + return WEEKDAY_KEYS.includes(canon) ? canon : WEEKDAY_KEYS[date.getUTCDay()]; +} + +/** + * ISO week key like 2026-W19. We derive the week from the user-tz + * day-key so a Sunday-evening reading in Auckland (which is already + * Monday UTC) labels under the Auckland week. + */ +export function isoWeekKey(date: Date, tz: string): string { + // Resolve the wall-clock date in the user's tz, then compute the ISO + // week from that calendar date. The Thursday-alignment math is the + // standard ISO 8601 algorithm. + const dayKey = tzDayKey(date, tz); + const [y, m, d] = dayKey.split("-").map(Number); + const localMidnight = new Date(Date.UTC(y, m - 1, d)); + const dayNum = localMidnight.getUTCDay() || 7; + localMidnight.setUTCDate(localMidnight.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(localMidnight.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil( + ((localMidnight.getTime() - yearStart.getTime()) / 86400000 + 1) / 7, + ); + return `${localMidnight.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`; +} + +export interface DailyValueRow { + date: string; + weekday: string; + value: number; +} + +interface DailyBpRow { + date: string; + weekday: string; + sys: number; + dia: number; +} + +interface WeeklyBucket { + weekISO: string; + mean: number; + count: number; +} + +/** + * Group raw measurements (one per row, possibly several per day) into + * one entry per user-tz day. Multiple readings on the same day are + * folded into the daily mean — clinically the morning reading is what + * the Coach is usually asked about, but the snapshot stays pre-clinical + * and takes the straight mean to avoid presenting a fabricated number. + */ +function dailyMeans( + rows: T[], + tz: string, +): Map { + const grouped = new Map(); + for (const r of rows) { + const key = tzDayKey(r.measuredAt, tz); + const existing = grouped.get(key); + if (existing) { + existing.values.push(r.value); + } else { + grouped.set(key, { date: r.measuredAt, values: [r.value] }); + } + } + return grouped; +} + +export function bucketWeekly( + rows: Array<{ measuredAt: Date; value: number }>, + tz: string, +): WeeklyBucket[] { + const grouped = new Map(); + for (const r of rows) { + const key = isoWeekKey(r.measuredAt, tz); + const list = grouped.get(key); + if (list) { + list.push(r.value); + } else { + grouped.set(key, [r.value]); + } + } + return Array.from(grouped.entries()) + .map(([weekISO, values]) => ({ + weekISO, + mean: + Math.round((values.reduce((s, v) => s + v, 0) / values.length) * 10) / + 10, + count: values.length, + })) + .sort((a, b) => a.weekISO.localeCompare(b.weekISO)); +} + +export function buildDailyValueRows( + rows: Array<{ measuredAt: Date; value: number }>, + recentCutoff: Date, + tz: string, +): DailyValueRow[] { + const recent = rows.filter((r) => r.measuredAt >= recentCutoff); + const grouped = dailyMeans(recent, tz); + return Array.from(grouped.entries()) + .map(([date, info]) => { + const mean = info.values.reduce((s, v) => s + v, 0) / info.values.length; + return { + date, + weekday: tzWeekday(info.date, tz), + value: Math.round(mean * 10) / 10, + }; + }) + .sort((a, b) => a.date.localeCompare(b.date)); +} + +/** + * v1.18.7 — coarse tail + anomaly envelope for one metric, from the shared + * tiered-context builder. The Coach already ships the 0–14d raw + 30–90d + * weekly band (above); this adds only the bands the weekly-fold CANNOT + * produce — 90d–1y MONTH, >1y YEAR, and the preserved peak/trough envelope — + * so a glucose spike or a BP outlier from months ago survives a coarse + * bucket. Token-bounded by the helper's per-band row caps (~10 month + ≤5 + * year + ≤5 anomalies ≈ 240 tokens), and the degrader sheds it first (it is + * the lowest-value, oldest detail). Returns `undefined` when the metric has + * no coarse history at all, so the block is omitted rather than shipped empty. + */ +export interface CoarseTimelineTail { + /** 90d–1y monthly buckets: `[bucketStart, mean, min, max]`. */ + monthly: Array<[string, number, number, number]>; + /** >1y yearly buckets: `[bucketStart, mean, min, max]`. */ + yearly: Array<[string, number, number, number]>; + /** Preserved peaks/troughs, ≤5, ranked by |deltaSd|. */ + anomalies: Array<{ + band: string; + date: string; + kind: string; + value: number; + deltaSd: number; + }>; +} + +export async function buildCoarseTimelineTail( + userId: string, + type: MeasurementType, + now: Date, + tz: string, +): Promise { + // `buildTieredSeries` reads fall back on miss — a coverage miss yields empty + // bands, which collapse to `undefined` here. The try/catch keeps the coarse + // tail a strictly additive enrichment: any unexpected reader failure simply + // drops the tail rather than failing the whole snapshot (matching the + // "fall back on miss" contract the rest of the snapshot honours). + let series: Awaited>; + try { + // `coarseOnly` — the snapshot already holds the raw 0–14d rows from its + // own window read and this tail consumes only the MONTH/YEAR bands + + // anomaly envelope, so the builder skips the raw read AND the DAY/WEEK + // band reads it would otherwise discard. + series = await buildTieredSeries(userId, type, { + now: now.getTime(), + tz, + coarseOnly: true, + }); + } catch { + return undefined; + } + const monthly = series.monthBand.map( + (b) => + [b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number], + ); + const yearly = series.yearBand.map( + (b) => + [b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number], + ); + if ( + monthly.length === 0 && + yearly.length === 0 && + series.anomalies.length === 0 + ) { + return undefined; + } + return { + monthly, + yearly, + anomalies: series.anomalies.map((a) => ({ + band: a.band, + date: a.date, + kind: a.kind, + value: a.value, + deltaSd: a.deltaSd, + })), + }; +} + +/** + * Pair systolic + diastolic into one row per day. Days with only one + * side measured are dropped — the Coach is asked about "BP" as a pair, + * and a half-measured day would invite a fabricated complement. + */ +export function buildDailyBpRows( + sysRows: Array<{ measuredAt: Date; value: number }>, + diaRows: Array<{ measuredAt: Date; value: number }>, + recentCutoff: Date, + tz: string, +): DailyBpRow[] { + const sysRecent = sysRows.filter((r) => r.measuredAt >= recentCutoff); + const diaRecent = diaRows.filter((r) => r.measuredAt >= recentCutoff); + const sysByDay = dailyMeans(sysRecent, tz); + const diaByDay = dailyMeans(diaRecent, tz); + const out: DailyBpRow[] = []; + for (const [day, info] of sysByDay) { + const dia = diaByDay.get(day); + if (!dia) continue; + const sysMean = info.values.reduce((s, v) => s + v, 0) / info.values.length; + const diaMean = dia.values.reduce((s, v) => s + v, 0) / dia.values.length; + out.push({ + date: day, + weekday: tzWeekday(info.date, tz), + sys: Math.round(sysMean), + dia: Math.round(diaMean), + }); + } + return out.sort((a, b) => a.date.localeCompare(b.date)); +} + +/** + * v1.22 (W6) — the user's own usual range for one BP component, computed from + * the rows already fetched for the snapshot (no extra DB read) using the SAME + * median ± k·MAD statistic as the baseline engine. Returns null below the + * engine's 7-day history floor so the Coach never quotes a fabricated band. + */ +export function bpBandFromRows( + rows: ReadonlyArray<{ measuredAt: Date; value: number }>, +): { low: number; high: number } | null { + const byDay = new Map(); + for (const r of rows) { + const day = r.measuredAt.toISOString().slice(0, 10); + const acc = byDay.get(day) ?? { sum: 0, count: 0 }; + acc.sum += r.value; + acc.count += 1; + byDay.set(day, acc); + } + const dayMeans = [...byDay.values()].map((a) => a.sum / a.count); + if (dayMeans.length < 7) return null; + const band = buildBaselineBand(dayMeans); + if (!band) return null; + return { low: Math.round(band.low), high: Math.round(band.high) }; +} + +/** + * Resolve the working scope. The explicit request `scope.sources` + * always wins as the maximum set (an iOS client can pin an exact + * list). When it is absent the builder expands the user's saved + * `dataClusters` instead — `clusterDefault` carries that expansion. + * When neither is present (no prefs row at all, legacy native client) + * the cluster resolver still returns `DEFAULT_COACH_CLUSTERS`, so the + * legacy five domains stay the floor. + * + * v1.7.0 — replaces the constant `DEFAULT_SOURCES` fallback with the + * cluster expansion threaded in by `buildCoachSnapshotImpl`. + */ +export function resolveScope( + scope: CoachScope | undefined, + clusterDefault: ReadonlySet, +): { + sources: ReadonlySet; + window: CoachScopeWindow; +} { + const sources = + scope?.sources && scope.sources.length > 0 + ? new Set(scope.sources) + : clusterDefault; + return { + sources, + window: scope?.window ?? DEFAULT_WINDOW, + }; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 420aafeb7..f57e88c1a 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -23,24 +23,8 @@ import { type CoachDataCluster, } from "@/lib/validations/coach-prefs"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; -import { userDayKey } from "@/lib/tz/format"; -import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; -import { - computeGlucoseClinicalMetrics, - GLUCOSE_PANEL_WINDOW_DAYS, -} from "@/lib/analytics/glucose-metrics"; -import { - reconstructSleepNights, - type SleepStageRow, -} from "@/lib/analytics/sleep-night"; -import { - reconstructNights, - sleepNeedMinutes, -} from "@/lib/insights/derived/sleep-score"; -import { - computeSleepRhythmFromNights, - type SleepRhythmDto, -} from "@/lib/insights/derived/sleep-rhythm"; +import { resolveGlucoseUnit } from "@/lib/glucose"; +import type { SleepStageRow } from "@/lib/analytics/sleep-night"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { annotate } from "@/lib/logging/context"; import { memoizePerRequest } from "@/lib/request-cache"; @@ -61,21 +45,12 @@ import { buildReferenceGroundingBlock, type GroundingMetricInput, } from "./reference-grounding"; -import { buildTieredSeries } from "@/lib/rollups/tiered-context"; import type { MeasurementType } from "@/generated/prisma/client"; import type { ReferenceMetric } from "@/lib/reference-ranges"; import { isCycleAvailableForUser } from "@/lib/cycle/gate"; import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; -import { - buildComplianceLedgerRows, - buildComplianceMedicationContext, - calculateCompliance, - lastNonSkippedTakenAt, - SCHEDULE_COMPLIANCE_SELECT, -} from "@/lib/analytics/compliance"; -import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; +import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; import type { BaselineProfile } from "@/lib/insights/derived"; -import { buildBaselineBand } from "@/lib/insights/derived/baseline"; import { CLUSTER_PRIORITY, clusterSourcesFromPrefs, @@ -88,6 +63,35 @@ import type { CoachScopeSource, CoachScopeWindow, } from "./types"; +import { + readSnapshotCache, + snapshotCacheKey, + writeSnapshotCache, +} from "./snapshot-cache"; +import { + bpBandFromRows, + bucketWeekly, + buildCoarseTimelineTail, + buildDailyBpRows, + buildDailyValueRows, + resolveScope, + windowToDays, + type CoarseTimelineTail, + type DailyValueRow, +} from "./snapshot-series"; +import { buildWorkoutsBlock } from "./snapshot-blocks/workouts-block"; +import { + buildGlucoseBlock, + GLUCOSE_CLINICAL_WINDOW_DAYS, +} from "./snapshot-blocks/glucose-block"; +import { + buildSleepRhythmBlock, + buildSleepTimelineBlock, +} from "./snapshot-blocks/sleep-block"; +import { buildComplianceBlock } from "./snapshot-blocks/compliance-block"; + +// Test-only escape hatch — the suites import it from this module. +export { __resetCoachSnapshotCacheForTests } from "./snapshot-cache"; export interface CoachSnapshotResult { snapshotJson: string; @@ -127,9 +131,6 @@ export interface CoachSnapshotResult { */ const DAILY_TIMELINE_DAYS = 14; -/** Default window when the caller doesn't pass a scope. */ -const DEFAULT_WINDOW: CoachScopeWindow = "last30days"; - /** * v1.18.10 (P-2) — newest-first cap on the single multi-type measurement read * that feeds the Coach snapshot timelines. The window read can reach 365 days @@ -143,15 +144,6 @@ const DEFAULT_WINDOW: CoachScopeWindow = "last30days"; */ const SNAPSHOT_MEASUREMENT_ROW_CAP = 6000; -/** - * v1.17.0 — the glucose clinical panel is a fixed trailing-30-day artifact, - * identical across the insights panel, the dashboard snapshot, the doctor - * report, and (here) the coach. Pinned independently of the coach's variable - * narration window so the coach's TIR/GMI/CV% always equals what the panel - * renders. - */ -const GLUCOSE_CLINICAL_WINDOW_DAYS = GLUCOSE_PANEL_WINDOW_DAYS; - /** * v1.17.0 — the sleep-rhythm read (sleep-debt + chronotype) is a fixed * trailing-window artifact, identical across the Sleep page, the dashboard @@ -185,12 +177,6 @@ const MAX_SNAPSHOT_CHARS = 24_000; const MULTI_CLUSTER_THRESHOLD = 6; const MULTI_CLUSTER_WINDOW_CAP: CoachScopeWindow = "last90days"; -/** - * v1.7.0 — the workouts block never dumps every session. It carries - * the most-recent N sessions verbatim plus a per-sport rollup for the - * tail, so a heavy-training account at a long window stays bounded. - */ -const WORKOUT_RECENT_CAP = 15; /** * Clusters that keep the user-chosen window even under the multi-cluster * cap — the high-signal clinical series. @@ -242,379 +228,6 @@ const MODULE_EXCLUDED_SOURCES: Partial> = recovery: ["hrv", "resting_hr", "vo2_max"], }; -const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; - -function windowToDays(window: CoachScopeWindow): number { - switch (window) { - case "last7days": - return 7; - case "last30days": - return 30; - case "last90days": - return 90; - case "lastYear": - // v1.4.27 B7 / BL-P6-4 — year-in-review window. Same 365-day - // ceiling as the historical `allTime` cap so the prompt budget - // stays bounded, but with explicit semantics — the Coach knows - // the user is asking about a long-horizon view rather than the - // unbounded "all time" backfill. - return 365; - case "allTime": - // Cap "allTime" at one year for the timeline. The aggregate - // section already cites multi-year ranges via the features - // pipeline; the timeline stays tight to keep token budget sane. - return 365; - } -} - -/** - * YYYY-MM-DD day key anchored to the user's display timezone. The - * prompt asks the Coach "did you read at 23:50 last night?" — that - * "last night" answer has to use the user's clock, not UTC. Up to - * v1.4.24 the day-key was UTC, so a 23:50 Pacific/Auckland reading - * landed in the next UTC day's bucket and the Coach couldn't pair it - * with the user's mental model. Delegates to the canonical - * `userDayKey` so every day-bucket surface stays byte-aligned. - */ -function tzDayKey(date: Date, tz: string): string { - return userDayKey(date, tz); -} - -/** - * 0..6 → "Sun".."Sat" using the user's tz so "last Monday" in the - * prompt agrees with the calendar the user is looking at. - */ -function tzWeekday(date: Date, tz: string): string { - const wk = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - weekday: "short", - }).format(date); - // Normalise "Mon" / "Mon," — modern engines drop the comma but - // older ones may include it. - const canon = wk.slice(0, 3) as (typeof WEEKDAY_KEYS)[number]; - return WEEKDAY_KEYS.includes(canon) ? canon : WEEKDAY_KEYS[date.getUTCDay()]; -} - -/** - * ISO week key like 2026-W19. We derive the week from the user-tz - * day-key so a Sunday-evening reading in Auckland (which is already - * Monday UTC) labels under the Auckland week. - */ -function isoWeekKey(date: Date, tz: string): string { - // Resolve the wall-clock date in the user's tz, then compute the ISO - // week from that calendar date. The Thursday-alignment math is the - // standard ISO 8601 algorithm. - const dayKey = tzDayKey(date, tz); - const [y, m, d] = dayKey.split("-").map(Number); - const localMidnight = new Date(Date.UTC(y, m - 1, d)); - const dayNum = localMidnight.getUTCDay() || 7; - localMidnight.setUTCDate(localMidnight.getUTCDate() + 4 - dayNum); - const yearStart = new Date(Date.UTC(localMidnight.getUTCFullYear(), 0, 1)); - const weekNo = Math.ceil( - ((localMidnight.getTime() - yearStart.getTime()) / 86400000 + 1) / 7, - ); - return `${localMidnight.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`; -} - -interface DailyValueRow { - date: string; - weekday: string; - value: number; -} - -interface DailyBpRow { - date: string; - weekday: string; - sys: number; - dia: number; -} - -interface WeeklyBucket { - weekISO: string; - mean: number; - count: number; -} - -/** - * Group raw measurements (one per row, possibly several per day) into - * one entry per user-tz day. Multiple readings on the same day are - * folded into the daily mean — clinically the morning reading is what - * the Coach is usually asked about, but the snapshot stays pre-clinical - * and takes the straight mean to avoid presenting a fabricated number. - */ -function dailyMeans( - rows: T[], - tz: string, -): Map { - const grouped = new Map(); - for (const r of rows) { - const key = tzDayKey(r.measuredAt, tz); - const existing = grouped.get(key); - if (existing) { - existing.values.push(r.value); - } else { - grouped.set(key, { date: r.measuredAt, values: [r.value] }); - } - } - return grouped; -} - -function bucketWeekly( - rows: Array<{ measuredAt: Date; value: number }>, - tz: string, -): WeeklyBucket[] { - const grouped = new Map(); - for (const r of rows) { - const key = isoWeekKey(r.measuredAt, tz); - const list = grouped.get(key); - if (list) { - list.push(r.value); - } else { - grouped.set(key, [r.value]); - } - } - return Array.from(grouped.entries()) - .map(([weekISO, values]) => ({ - weekISO, - mean: - Math.round((values.reduce((s, v) => s + v, 0) / values.length) * 10) / - 10, - count: values.length, - })) - .sort((a, b) => a.weekISO.localeCompare(b.weekISO)); -} - -function buildDailyValueRows( - rows: Array<{ measuredAt: Date; value: number }>, - recentCutoff: Date, - tz: string, -): DailyValueRow[] { - const recent = rows.filter((r) => r.measuredAt >= recentCutoff); - const grouped = dailyMeans(recent, tz); - return Array.from(grouped.entries()) - .map(([date, info]) => { - const mean = info.values.reduce((s, v) => s + v, 0) / info.values.length; - return { - date, - weekday: tzWeekday(info.date, tz), - value: Math.round(mean * 10) / 10, - }; - }) - .sort((a, b) => a.date.localeCompare(b.date)); -} - -/** - * v1.18.7 — coarse tail + anomaly envelope for one metric, from the shared - * tiered-context builder. The Coach already ships the 0–14d raw + 30–90d - * weekly band (above); this adds only the bands the weekly-fold CANNOT - * produce — 90d–1y MONTH, >1y YEAR, and the preserved peak/trough envelope — - * so a glucose spike or a BP outlier from months ago survives a coarse - * bucket. Token-bounded by the helper's per-band row caps (~10 month + ≤5 - * year + ≤5 anomalies ≈ 240 tokens), and the degrader sheds it first (it is - * the lowest-value, oldest detail). Returns `undefined` when the metric has - * no coarse history at all, so the block is omitted rather than shipped empty. - */ -interface CoarseTimelineTail { - /** 90d–1y monthly buckets: `[bucketStart, mean, min, max]`. */ - monthly: Array<[string, number, number, number]>; - /** >1y yearly buckets: `[bucketStart, mean, min, max]`. */ - yearly: Array<[string, number, number, number]>; - /** Preserved peaks/troughs, ≤5, ranked by |deltaSd|. */ - anomalies: Array<{ - band: string; - date: string; - kind: string; - value: number; - deltaSd: number; - }>; -} - -async function buildCoarseTimelineTail( - userId: string, - type: MeasurementType, - now: Date, - tz: string, -): Promise { - // `buildTieredSeries` reads fall back on miss — a coverage miss yields empty - // bands, which collapse to `undefined` here. The try/catch keeps the coarse - // tail a strictly additive enrichment: any unexpected reader failure simply - // drops the tail rather than failing the whole snapshot (matching the - // "fall back on miss" contract the rest of the snapshot honours). - let series: Awaited>; - try { - // `coarseOnly` — the snapshot already holds the raw 0–14d rows from its - // own window read and this tail consumes only the MONTH/YEAR bands + - // anomaly envelope, so the builder skips the raw read AND the DAY/WEEK - // band reads it would otherwise discard. - series = await buildTieredSeries(userId, type, { - now: now.getTime(), - tz, - coarseOnly: true, - }); - } catch { - return undefined; - } - const monthly = series.monthBand.map( - (b) => - [b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number], - ); - const yearly = series.yearBand.map( - (b) => - [b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number], - ); - if ( - monthly.length === 0 && - yearly.length === 0 && - series.anomalies.length === 0 - ) { - return undefined; - } - return { - monthly, - yearly, - anomalies: series.anomalies.map((a) => ({ - band: a.band, - date: a.date, - kind: a.kind, - value: a.value, - deltaSd: a.deltaSd, - })), - }; -} - -/** - * Pair systolic + diastolic into one row per day. Days with only one - * side measured are dropped — the Coach is asked about "BP" as a pair, - * and a half-measured day would invite a fabricated complement. - */ -function buildDailyBpRows( - sysRows: Array<{ measuredAt: Date; value: number }>, - diaRows: Array<{ measuredAt: Date; value: number }>, - recentCutoff: Date, - tz: string, -): DailyBpRow[] { - const sysRecent = sysRows.filter((r) => r.measuredAt >= recentCutoff); - const diaRecent = diaRows.filter((r) => r.measuredAt >= recentCutoff); - const sysByDay = dailyMeans(sysRecent, tz); - const diaByDay = dailyMeans(diaRecent, tz); - const out: DailyBpRow[] = []; - for (const [day, info] of sysByDay) { - const dia = diaByDay.get(day); - if (!dia) continue; - const sysMean = info.values.reduce((s, v) => s + v, 0) / info.values.length; - const diaMean = dia.values.reduce((s, v) => s + v, 0) / dia.values.length; - out.push({ - date: day, - weekday: tzWeekday(info.date, tz), - sys: Math.round(sysMean), - dia: Math.round(diaMean), - }); - } - return out.sort((a, b) => a.date.localeCompare(b.date)); -} - -/** - * Resolve the working scope. The explicit request `scope.sources` - * always wins as the maximum set (an iOS client can pin an exact - * list). When it is absent the builder expands the user's saved - * `dataClusters` instead — `clusterDefault` carries that expansion. - * When neither is present (no prefs row at all, legacy native client) - * the cluster resolver still returns `DEFAULT_COACH_CLUSTERS`, so the - * legacy five domains stay the floor. - * - * v1.7.0 — replaces the constant `DEFAULT_SOURCES` fallback with the - * cluster expansion threaded in by `buildCoachSnapshotImpl`. - */ -function resolveScope( - scope: CoachScope | undefined, - clusterDefault: ReadonlySet, -): { - sources: ReadonlySet; - window: CoachScopeWindow; -} { - const sources = - scope?.sources && scope.sources.length > 0 - ? new Set(scope.sources) - : clusterDefault; - return { - sources, - window: scope?.window ?? DEFAULT_WINDOW, - }; -} - -/** - * v1.4.33 — 60-second in-memory cache for `buildCoachSnapshot()`. The - * snapshot reads only persisted data; a single chat conversation sends - * 2-4 turns within a minute and the snapshot would otherwise rebuild - * from the same rows each turn. Caching the result for 60s shaves the - * ~10 measurement reads + the GLP-1 / mood / intake side-fetches off - * every turn after the first, which `.planning/round-v1433-audit-perf.md` - * §3.3 estimates at 200-800 ms of server-side tail. - * - * Scope is part of the cache key so a switch from `last30days` to - * `last7days` (or a different `sources` set) computes fresh. The map - * is bounded at 64 entries — a multi-tenant deployment with a few - * active power users sits well inside that ceiling even if each cycles - * through several scopes per minute. - */ -const SNAPSHOT_TTL_MS = 60_000; -const SNAPSHOT_LRU_MAX = 64; -const snapshotCache = new Map< - string, - { expiresAt: number; result: CoachSnapshotResult } ->(); - -function snapshotCacheKey( - userId: string, - scope: CoachScope | undefined, -): string { - // v1.7.0 — when the request pins an explicit source list, key on it. - // Otherwise the source set is derived from the user's saved - // `dataClusters`, which we don't read here (the cache must stay - // I/O-free on a hit) — key on a stable `clusters` marker instead. - // A cluster change is reflected on the next cache miss (≤60 s), the - // same staleness window every other pref change already tolerates. - const window = scope?.window ?? DEFAULT_WINDOW; - const sourceList = - scope?.sources && scope.sources.length > 0 - ? Array.from(scope.sources).sort().join(",") - : "clusters"; - return `${userId}|${window}|${sourceList}`; -} - -function readSnapshotCache(key: string): CoachSnapshotResult | null { - const entry = snapshotCache.get(key); - if (!entry) return null; - if (entry.expiresAt <= Date.now()) { - snapshotCache.delete(key); - return null; - } - // Touch for LRU — re-insert moves to the end of the Map's iteration order. - snapshotCache.delete(key); - snapshotCache.set(key, entry); - return entry.result; -} - -function writeSnapshotCache(key: string, result: CoachSnapshotResult): void { - if (snapshotCache.size >= SNAPSHOT_LRU_MAX) { - // Evict the oldest entry — JS Map iteration order is insertion order, - // so the first key is the least-recently inserted/touched. - const oldest = snapshotCache.keys().next().value; - if (oldest !== undefined) { - snapshotCache.delete(oldest); - } - } - snapshotCache.set(key, { - expiresAt: Date.now() + SNAPSHOT_TTL_MS, - result, - }); -} - -/** Clear the snapshot cache. Test-only escape hatch. */ -export function __resetCoachSnapshotCacheForTests(): void { - snapshotCache.clear(); -} - /** * Build the Coach prompt snapshot for `userId`. Always uses * `includeRaw=false` because the Coach replies are conversational and @@ -640,30 +253,6 @@ export function __resetCoachSnapshotCacheForTests(): void { * chat handler calls this once per turn; within the same conversation * the second+ turn lands a cache hit and skips the row-level reads. */ -/** - * v1.22 (W6) — the user's own usual range for one BP component, computed from - * the rows already fetched for the snapshot (no extra DB read) using the SAME - * median ± k·MAD statistic as the baseline engine. Returns null below the - * engine's 7-day history floor so the Coach never quotes a fabricated band. - */ -function bpBandFromRows( - rows: ReadonlyArray<{ measuredAt: Date; value: number }>, -): { low: number; high: number } | null { - const byDay = new Map(); - for (const r of rows) { - const day = r.measuredAt.toISOString().slice(0, 10); - const acc = byDay.get(day) ?? { sum: 0, count: 0 }; - acc.sum += r.value; - acc.count += 1; - byDay.set(day, acc); - } - const dayMeans = [...byDay.values()].map((a) => a.sum / a.count); - if (dayMeans.length < 7) return null; - const band = buildBaselineBand(dayMeans); - if (!band) return null; - return { low: Math.round(band.low), high: Math.round(band.high) }; -} - export async function buildCoachSnapshot( userId: string, scope?: CoachScope, @@ -1494,142 +1083,21 @@ async function buildCoachSnapshotImpl( registerBlock("mood", "mood"); } + // Medication compliance lives outside the structured features — the block + // is assembled in `snapshot-blocks/compliance-block.ts` from the ledger + // reads above. if (wantsCompliance && complianceMeds) { - // Medication compliance lives outside the structured features today — - // the legacy Coach surface labelled it as "general" provenance only. - // v1.4.20.1 shipped a per-day adherence row; v1.16.9 derives it from - // the band-engine LEDGER (the same expansion the compliance % and the - // dose-history view consume): a slot counts against the rate only once - // it is genuinely missed, a pending/upcoming slot never reads as "not - // taken", deliberate skips and ad-hoc takes stay out of the - // denominator, and cross-source duplicate rows collapse onto one slot. - const ledgerRows: DoseHistoryRow[] = []; - // v1.17 W1c — the coach's headline adherence figure routes through the - // SAME `calculateCompliance(...).rate` ledger authority the medication - // card shows (the ledger path, `medicationContext` supplied), so the - // coach can never quote a denominator the card doesn't use. Per - // medication we take the ledger numerator (on-time + late takes) and - // denominator (taken + missed) and pool them across the user's - // scheduled medications: for a single medication the headline equals - // that med's card rate exactly; for several it is the dose-weighted - // overall adherence (the same pooling the cross-med timeline below - // already uses), never a per-day / per-week denominator of its own. - let complianceTaken = 0; - let complianceDenominator = 0; - const windowDaysForRate = Math.max( - 1, - Math.round((now.getTime() - cutoff.getTime()) / (24 * 60 * 60 * 1000)), - ); - for (const med of complianceMeds) { - if (med.schedules.length === 0) continue; - const ctx = buildComplianceMedicationContext( - med, - lastNonSkippedTakenAt(med.intakeEvents), - userTz, - ); - ledgerRows.push( - ...buildComplianceLedgerRows( - med.intakeEvents, - med.schedules, - ctx, - cutoff, - now, - now, - ), - ); - // The card's rate IS `calculateCompliance(...).rate` over the ledger; - // aggregate the same taken / (taken + missed) counts here so the - // coach's single headline % equals what the card renders. - const result = calculateCompliance( - med.intakeEvents, - med.schedules, - windowDaysForRate, - med.createdAt, - { now, medicationContext: ctx }, - ); - complianceTaken += result.taken; - complianceDenominator += result.taken + result.missed; - } - // Countable rows: taken (on-time or late) or genuinely missed. The - // pending / upcoming / skipped / ad-hoc rows carry no adherence signal. - const countable = ledgerRows.filter( - (r) => - r.status === "taken_on_time" || - r.status === "taken_late" || - r.status === "missed", - ); - if (countable.length > 0) { - const recent = countable.filter((r) => r.at >= recentCutoff); - const olderRows = countable.filter((r) => r.at < recentCutoff); - const isTaken = (r: DoseHistoryRow) => - r.status === "taken_on_time" || r.status === "taken_late"; - const recentByDay = new Map< - string, - { date: Date; total: number; taken: number } - >(); - for (const r of recent) { - const key = tzDayKey(r.at, userTz); - const e = recentByDay.get(key) ?? { - date: r.at, - total: 0, - taken: 0, - }; - e.total += 1; - if (isTaken(r)) e.taken += 1; - recentByDay.set(key, e); - } - const recentRows = Array.from(recentByDay.entries()) - .map(([date, info]) => ({ - date, - weekday: tzWeekday(info.date, userTz), - rate: Math.round((info.taken / info.total) * 100) / 100, - taken: info.taken, - total: info.total, - })) - .sort((a, b) => a.date.localeCompare(b.date)); - const olderByWeek = new Map(); - for (const r of olderRows) { - const key = isoWeekKey(r.at, userTz); - const e = olderByWeek.get(key) ?? { taken: 0, total: 0 }; - e.total += 1; - if (isTaken(r)) e.taken += 1; - olderByWeek.set(key, e); - } - const weeklyRows = Array.from(olderByWeek.entries()) - .map(([weekISO, v]) => ({ - weekISO, - rate: v.total > 0 ? Math.round((v.taken / v.total) * 100) / 100 : 0, - taken: v.taken, - total: v.total, - })) - .sort((a, b) => a.weekISO.localeCompare(b.weekISO)); - snapshot.compliance = { - // v1.17 W1c — headline adherence % from the SAME ledger authority - // (`calculateCompliance(...).rate`) the medication card shows: the - // dose-weighted pool of taken / (taken + missed) across the user's - // scheduled medications, so the coach quotes the card's figure - // (single med) or its honest overall adherence (several meds) rather - // than a per-day / per-week rate built off a different denominator. - // Integer 0-100 to match the card's rounding; null when no scheduled - // medication has any countable dose in the window. - rate: - complianceDenominator > 0 - ? Math.round((complianceTaken / complianceDenominator) * 100) - : null, - timeline: { recent: recentRows, weekly: weeklyRows }, - }; - metrics.add("compliance"); - counts.compliance = countable.length; - registerBlock("compliance", "compliance"); - } else { - // v1.7.0 — toggled-on cluster with no rows. Annotate so the - // dashboards can distinguish "user has no medication data" from - // "medication cluster was off". - annotate({ - action: { name: "coach.cluster.empty_skipped" }, - meta: { cluster: "medication" }, - }); - } + buildComplianceBlock({ + complianceMeds, + userTz, + cutoff, + recentCutoff, + now, + snapshot, + metrics, + counts, + registerBlock, + }); } // ── v1.4.23 Apple Health additive blocks ───────────────────────── @@ -1897,335 +1365,69 @@ async function buildCoachSnapshotImpl( } // ── v1.7.0 sleep block (with optional per-stage enrichment) ─────── - // - // Sleep needs the `sleepStage` column so the Coach can narrate REM / - // core / deep / awake minutes per night instead of a flat duration. - // The shared `byType("SLEEP_DURATION")` rows above already cover the - // duration timeline, but they drop the stage label — so the sleep - // branch builds its own block. Per-night stage minutes come from a - // dedicated read of the SLEEP_DURATION rows that carry a non-null - // stage; the duration timeline is built from the same rows summed per - // night (one night = the sum of its per-stage rows). + // Assembled in `snapshot-blocks/sleep-block.ts` from the dedicated + // stage-bearing rows read in parallel above. if (sources.has("sleep") && sleepRows) { - // The SLEEP_DURATION rows (with the `sleepStage` column) are read in - // parallel above. - if (sleepRows.length === 0) { - annotate({ - action: { name: "coach.cluster.empty_skipped" }, - meta: { cluster: "sleep", source: "sleep" }, - }); - } else { - // v1.11.5 — reconstruct per-night TIME-ASLEEP totals through the shared - // helper so the Coach narrates the same nightly numbers every other - // sleep surface shows: stages clustered into sessions, a dual-source - // night collapsed to one canonical source, and the granular - // CORE/DEEP/REM partition counted WITHOUT double-counting the bare - // ASLEEP aggregate Apple Health writes alongside it. IN_BED + AWAKE are - // excluded from the asleep total. - const nights = reconstructSleepNights( - sleepRows as SleepStageRow[], - userTz, - prefsRow?.sourcePriorityJson ?? null, - ).filter((n) => n.asleepMinutes > 0); - // Recent nights: asleep duration + stage breakdown when present. - const recentNights = nights - .filter((n) => n.measuredAt >= recentCutoff) - .map((n) => { - const row: Record = { - date: n.measuredAt, - weekday: tzWeekday(n.measuredAt, userTz), - minutes: Math.round(n.asleepMinutes), - }; - const stageEntries = Object.entries(n.stages).filter( - ([stage]) => stage !== "IN_BED" && stage !== "AWAKE", - ); - if (stageEntries.length > 0) { - row.stages = Object.fromEntries( - stageEntries.map(([k, v]) => [ - k.toLowerCase(), - Math.round(v as number), - ]), - ); - } - return row; - }) - .sort((a, b) => String(a.date).localeCompare(String(b.date))); - const olderNights = nights - .filter((n) => n.measuredAt < recentCutoff) - .map((n) => ({ measuredAt: n.measuredAt, value: n.asleepMinutes })); - snapshot.sleep = { - timeline: { - recent: recentNights, - weekly: bucketWeekly(olderNights, userTz), - }, - }; - metrics.add("sleep"); - counts.sleep = sleepRows.length; - registerBlock("sleep", "sleep"); - // W7 grounding: recent nightly asleep duration in HOURS against the - // AASM 7–9 h band (the reference unit is hours; the snapshot stores - // minutes). Mean over the recent nights the block already reconstructed. - if (recentNights.length > 0) { - const meanMin = - recentNights.reduce( - (s, n) => s + (typeof n.minutes === "number" ? n.minutes : 0), - 0, - ) / recentNights.length; - if (meanMin > 0) { - groundingValues.set("SLEEP_DURATION", meanMin / 60); - } - } - } + buildSleepTimelineBlock({ + sleepRows: sleepRows as SleepStageRow[], + sourcePriorityJson: prefsRow?.sourcePriorityJson ?? null, + userTz, + recentCutoff, + snapshot, + metrics, + counts, + registerBlock, + groundingValues, + }); } // ── v1.17.0 sleep-rhythm block (sleep-debt + chronotype) ────────── - // - // The two server-authoritative timing signals the Sleep page + the - // dashboard summary render — cumulative sleep debt and the MCTQ - // chronotype band + social jetlag. Built from the SAME assembler the - // dashboard route uses (`reconstructNights` → `computeSleepRhythmFromNights`), - // over the rhythm's OWN fixed trailing-42-day rows, so the coach quotes the - // exact debt + band the page shows regardless of the coach's narration - // window. ONE ENGINE: this never recomputes sleep-debt or chronotype inline — - // the math lives in `sleep-debt.ts` / `chronotype.ts`, reached through the - // assembler. The `needMinutes` is the SAME age-resolved need the derived - // block + Sleep Score read (`sleepNeedMinutes(ageYears)`), and the - // `sourcePriorityJson` is the one already loaded for the per-stage sleep - // block above — no extra read beyond the rhythm rows. - // - // LEARNING-GATE HONESTY: both signals carry a calm `partial` / `learning` - // state below their night thresholds. The chronotype `band` is emitted ONLY - // when the state is `ready` — a learning chronotype is surfaced as - // "still calibrating", never asserted as a band the data can't support. + // Assembled in `snapshot-blocks/sleep-block.ts` from the rhythm's own + // fixed trailing-42-day rows read in parallel above. if (sources.has("sleep") && sleepRhythmRows && sleepRhythmRows.length > 0) { - const rhythm: SleepRhythmDto = computeSleepRhythmFromNights( - reconstructNights( - sleepRhythmRows as SleepStageRow[], - userTz, - prefsRow?.sourcePriorityJson ?? null, - ), - sleepNeedMinutes(derivedProfile.ageYears), - ); - const chronotypeReady = rhythm.chronotype.state === "ready"; - snapshot.sleepRhythm = { - sleepDebt: { - state: rhythm.sleepDebt.state, - debtMinutes: rhythm.sleepDebt.debtMinutes, - needMinutes: rhythm.sleepDebt.needMinutes, - }, - chronotype: { - state: rhythm.chronotype.state, - // Only assert a band + social jetlag once the chronotype is `ready`. - // A `learning` chronotype carries no band the data supports — the - // model treats it as "still calibrating", never a typed assertion. - band: chronotypeReady ? rhythm.chronotype.band : null, - socialJetlagMinutes: chronotypeReady - ? rhythm.chronotype.socialJetlagMinutes - : null, - }, - }; - metrics.add("sleep"); - registerBlock("sleepRhythm", "sleep"); + buildSleepRhythmBlock({ + sleepRhythmRows: sleepRhythmRows as SleepStageRow[], + sourcePriorityJson: prefsRow?.sourcePriorityJson ?? null, + userTz, + ageYears: derivedProfile.ageYears, + snapshot, + metrics, + registerBlock, + }); } // ── v1.7.0 glucose block (per-context daily means) ──────────────── - // - // Glucose is summarised per `GlucoseContext` so the Coach can tell - // fasting from postprandial without seeing raw samples. Each context - // gets its own per-day mean timeline; the block omits when no rows. + // Assembled in `snapshot-blocks/glucose-block.ts` from the shared + // measurement read + the fixed-window clinical rows read above. if (sources.has("glucose")) { - const glucoseCutoff = additiveCutoff("glucose"); - const glucoseRows = measurementRows.filter( - (r) => r.type === "BLOOD_GLUCOSE" && r.measuredAt >= glucoseCutoff, - ); - if (glucoseRows.length === 0) { - annotate({ - action: { name: "coach.cluster.empty_skipped" }, - meta: { cluster: "glucose", source: "glucose" }, - }); - } else { - // Group by context (NULL → "unspecified"), then per-day mean. - const byContext = new Map< - string, - Array<{ measuredAt: Date; value: number }> - >(); - for (const r of glucoseRows) { - const ctx = r.glucoseContext - ? String(r.glucoseContext).toLowerCase() - : "unspecified"; - const list = byContext.get(ctx) ?? []; - list.push({ measuredAt: r.measuredAt, value: r.value }); - byContext.set(ctx, list); - } - const contexts: Record = {}; - for (const [ctx, rows] of byContext) { - // v1.16.16 — glucose is stored canonical mg/dL. A mmol/L-preference - // user's Coach must read the same number every other surface shows - // (5.5, not 100). Aggregate the per-day / weekly means in raw mg/dL, - // then convert each resulting figure ONCE (parity with the series - // DTO + detail page + FHIR). mg/dL users stay byte-identical because - // the conversion is skipped entirely. - const recent = buildDailyValueRows(rows, recentCutoff, userTz).map( - (d) => - glucoseUnit === "mmol/L" - ? { ...d, value: convertGlucose(d.value, glucoseUnit) } - : d, - ); - const weekly = bucketWeekly( - rows.filter((r) => r.measuredAt < recentCutoff), - userTz, - ).map((w) => - glucoseUnit === "mmol/L" - ? { ...w, mean: convertGlucose(w.mean, glucoseUnit) } - : w, - ); - contexts[ctx] = { recent, weekly }; - } - // v1.17.0 — clinical panel summary from the ONE literature-locked engine - // the insights panel + doctor report also consume, computed over the SAME - // fixed trailing-30-day window + rows the panel uses (`glucoseClinicalRows`, - // not the coach-window / cap-trimmed `glucoseRows`), so the coach can never - // quote a TIR / GMI / CV% figure the panel doesn't show — true numeric - // parity, independent of the user's coach scope. Gated by `stillLearning` - // so a thin spot-data window is offered as a calm "still learning" note - // rather than asserted as a clinical AGP. The headline mean is converted - // ONCE to the user's display unit; the unit-agnostic fractions / indices - // travel as-is. - const clinicalRaw = computeGlucoseClinicalMetrics( - (glucoseClinicalRows ?? []).map((r) => ({ - measuredAt: r.measuredAt, - mgdl: r.value, - })), - { windowDays: GLUCOSE_CLINICAL_WINDOW_DAYS, now }, - ); - const clinical = clinicalRaw.stillLearning - ? { - stillLearning: true as const, - reason: clinicalRaw.stillLearningReason, - readingCount: clinicalRaw.readingCount, - spanDays: Math.round(clinicalRaw.actualSpanDays), - } - : { - stillLearning: false as const, - windowDays: clinicalRaw.windowDays, - spanDays: Math.round(clinicalRaw.actualSpanDays), - readingCount: clinicalRaw.readingCount, - meanInRange: - clinicalRaw.meanMgdl !== null - ? Math.round( - convertGlucose(clinicalRaw.meanMgdl, glucoseUnit) * - (glucoseUnit === "mmol/L" ? 10 : 1), - ) / (glucoseUnit === "mmol/L" ? 10 : 1) - : null, - tirPercent: clinicalRaw.distribution - ? Math.round(clinicalRaw.distribution.tir * 100) - : null, - timeBelowPercent: clinicalRaw.distribution - ? Math.round(clinicalRaw.distribution.tbrLevel1 * 100) - : null, - timeAbovePercent: clinicalRaw.distribution - ? Math.round(clinicalRaw.distribution.tarLevel1 * 100) - : null, - gmi: - clinicalRaw.gmi !== null - ? Math.round(clinicalRaw.gmi * 10) / 10 - : null, - estimatedA1c: - clinicalRaw.estimatedA1c !== null - ? Math.round(clinicalRaw.estimatedA1c * 10) / 10 - : null, - cvPercent: clinicalRaw.variability - ? Math.round(clinicalRaw.variability.cv) - : null, - unstable: clinicalRaw.variability?.unstable ?? null, - // Density-derived: a sparse spot series stays a spot-reading - // estimate, a continuous CGM stream (Nightscout) reads false so the - // model can narrate the TIR/GMI as continuous-trace figures. - isSpotEstimate: clinicalRaw.isSpotEstimate, - }; - // The display unit travels with the block so the prompt renders - // " " and the EVIDENCE BLOCK tags glucose lines correctly. - snapshot.glucose = { unit: glucoseUnit, byContext: contexts, clinical }; - metrics.add("glucose"); - counts.glucose = glucoseRows.length; - registerBlock("glucose", "glucose"); - // W7 grounding: fasting glucose mean in RAW mg/dL (the reference band's - // unit), independent of the user's mmol/L display preference. The - // grounding line's band selection respects the W6 `hasDiabetes` opt-in; - // here we only feed the representative fasting value. Fall back to the - // overall mean when no row is tagged FASTING so the band is still cited. - const fastingRows = glucoseRows.filter( - (r) => String(r.glucoseContext).toUpperCase() === "FASTING", - ); - const glucoseScalarRows = - fastingRows.length > 0 ? fastingRows : glucoseRows; - const glucoseMeanMgdl = - glucoseScalarRows.reduce((s, r) => s + r.value, 0) / - glucoseScalarRows.length; - if (Number.isFinite(glucoseMeanMgdl)) { - groundingValues.set("BLOOD_GLUCOSE", glucoseMeanMgdl); - } - } + buildGlucoseBlock({ + measurementRows, + glucoseCutoff: additiveCutoff("glucose"), + glucoseClinicalRows, + glucoseUnit, + recentCutoff, + userTz, + now, + snapshot, + metrics, + counts, + registerBlock, + groundingValues, + }); } // ── v1.7.0 workouts block (capped list + per-sport rollup) ──────── - // - // The Workout model is never dumped row-for-row. The block carries - // the most recent `WORKOUT_RECENT_CAP` sessions (sport, duration, - // energy, distance, avg/max HR) plus a per-sport weekly count + total - // duration/energy rollup for the tail so the prompt stays bounded - // even for a heavy-training account at a long window. + // Assembled in `snapshot-blocks/workouts-block.ts` from the rows read + // in parallel above. if (sources.has("workouts") && workoutRows) { - // The workout sessions are read in parallel above. - if (workoutRows.length === 0) { - annotate({ - action: { name: "coach.cluster.empty_skipped" }, - meta: { cluster: "workouts", source: "workouts" }, - }); - } else { - const recentList = workoutRows.slice(0, WORKOUT_RECENT_CAP).map((w) => ({ - date: tzDayKey(w.startedAt, userTz), - weekday: tzWeekday(w.startedAt, userTz), - sport: w.sportType, - durationMin: Math.round(w.durationSec / 60), - energyKcal: w.totalEnergyKcal ?? null, - distanceM: w.totalDistanceM ?? null, - avgHr: w.avgHeartRate ?? null, - maxHr: w.maxHeartRate ?? null, - })); - // Per-sport rollup over the whole window. - const bySport = new Map< - string, - { count: number; durationMin: number; energyKcal: number } - >(); - for (const w of workoutRows) { - const e = bySport.get(w.sportType) ?? { - count: 0, - durationMin: 0, - energyKcal: 0, - }; - e.count += 1; - e.durationMin += Math.round(w.durationSec / 60); - e.energyKcal += w.totalEnergyKcal ?? 0; - bySport.set(w.sportType, e); - } - const sportRollup = Array.from(bySport.entries()) - .map(([sport, v]) => ({ - sport, - count: v.count, - totalDurationMin: v.durationMin, - totalEnergyKcal: Math.round(v.energyKcal), - })) - .sort((a, b) => b.count - a.count); - snapshot.workouts = { - recent: recentList, - perSport: sportRollup, - totalInWindow: workoutRows.length, - }; - metrics.add("workouts"); - counts.workouts = workoutRows.length; - registerBlock("workouts", "workouts"); - } + buildWorkoutsBlock({ + workoutRows, + userTz, + snapshot, + metrics, + counts, + registerBlock, + }); } // ── v1.4.25 W4d — GLP-1 weeklyContext block ────────────────────── diff --git a/src/lib/analytics/__tests__/glucose-panel-window.test.ts b/src/lib/analytics/__tests__/glucose-panel-window.test.ts index a92237d02..56bfa8e60 100644 --- a/src/lib/analytics/__tests__/glucose-panel-window.test.ts +++ b/src/lib/analytics/__tests__/glucose-panel-window.test.ts @@ -22,7 +22,9 @@ const ROOT = join(__dirname, "..", "..", "..", ".."); const CONSUMERS = [ "src/app/api/analytics/route.ts", "src/lib/dashboard/snapshot.ts", - "src/lib/ai/coach/snapshot.ts", + // v1.28.26 — the Coach glucose section moved into its own block module; + // the alias derivation lives there and snapshot.ts imports the alias. + "src/lib/ai/coach/snapshot-blocks/glucose-block.ts", ]; describe("glucose panel window constant", () => { diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 5c0e0734d..8f533c90b 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -6,6 +6,10 @@ * `/api/doctor-report/pdf` (server-rendered PDF). Keeps the two endpoints * structurally identical so visual parity between client- and server-rendered * PDFs is guaranteed by construction, not by drift-prone copy-paste. + * + * The data shapes live in `doctor-report-types.ts` and the pure helpers in + * `doctor-report-helpers.ts`; both are re-exported here so call sites keep a + * single import surface. */ import { prisma } from "@/lib/db"; @@ -13,27 +17,14 @@ import { getEffectiveRange, type ThresholdOverridesJson, } from "@/lib/analytics/effective-range"; -import { - resolveGlucoseUnit, - thresholdMetricForContext, - type GlucoseUnit, -} from "@/lib/glucose"; -import { - computeGlucoseClinicalMetrics, - type GlucoseClinicalMetrics, -} from "@/lib/analytics/glucose-metrics"; +import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose"; +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; import type { GlucoseContext, - MeasurementSource, MeasurementType, } from "@/generated/prisma/client"; -import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { getEvent } from "@/lib/logging/context"; import { readNote } from "@/lib/crypto/note-cipher"; -import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; -import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; -import { userDayKey } from "@/lib/tz/resolver"; -import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; import { reconstructSleepNights, type SleepStageRow, @@ -44,478 +35,27 @@ import { } from "@/lib/validations/doctor-report-prefs"; import { buildCycleExportSummary } from "@/lib/cycle/export-data"; import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; +import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; import { - buildComplianceMedicationContext, - lastNonSkippedTakenAt, - tallyComplianceFromLedger, - SCHEDULE_COMPLIANCE_SELECT, - type ComplianceSchedule, - type IntakeEvent, - type MedicationPauseEraLike, -} from "@/lib/analytics/compliance"; -import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; + WELLNESS_SCORE_REPORT_TYPES, + type CollectDoctorReportOptions, + type DoctorReportData, + type DoctorReportMood, + type DoctorReportRange, + type DoctorReportStats, +} from "./doctor-report-types"; +import { + buildLedgerCompliance, + collapseMeasurementsToCanonical, + decryptAllergyReaction, + minMaxOf, + resolveMaxMedicationAdministrations, + sanitisePracticeName, + summariseCanonicalRecovery, +} from "./doctor-report-helpers"; -export interface DoctorReportStats { - avg: number; - min: number; - max: number; - count: number; - latest: number; -} - -export interface DoctorReportCompliance { - total: number; - taken: number; - skipped: number; - missed: number; -} - -/** - * Canonical adherence-rate rounding for the doctor-report export surfaces. - * - * The PDF table, the PDF clinical-summary headline, the FHIR adherence - * Observation, and the GLP-1 block all derive the rate from the SAME ledger - * denominator (`taken / total`, `total = taken + missed`). They MUST round it - * the same way the app does so a clinician comparing the export to the in-app - * card never sees a presentational divergence (87 % on the card, 87.3 % on the - * PDF). The app convention is `round(100 · taken / denominator)` capped at 100 - * (see `dose-history-ledger-compute.ts` + `tallyComplianceFromLedger`) — an - * integer percent. This is the one source of truth for every export surface. - * - * Returns `null` when `total <= 0` so callers render the "no expected dose" - * placeholder instead of a misleading `0` or `100`. - */ -export function adherenceRatePercent( - taken: number, - total: number, -): number | null { - if (total <= 0) return null; - return Math.min(100, Math.round((taken / total) * 100)); -} - -export interface DoctorReportMood { - avg: number; - min: number; - max: number; - count: number; - distribution: Record; -} - -export interface DoctorReportData { - /** - * Reporting period bounds. `start` and `end` are ISO timestamps; `days` is - * the inclusive day-count between the two (rounded up) so existing callers - * that displayed "last N days" continue to work without a UI rewrite. - */ - period: { days: number; since: string; start: string; end: string }; - patient: { - username: string | null; - dateOfBirth: string | null; - gender: string | null; - heightCm: number | null; - // v1.7.0 — optional patient-identity fields for the export cover + - // FHIR Patient. `fullName` + `insurerName` are plaintext columns; the - // KVNR is NOT carried here (it's encrypted at rest and decrypted by - // the route, then handed to the builders separately). Optional in the - // type so pre-v1.7.0 fixtures that omit them still typecheck — the - // renderer treats absent and null identically. - fullName?: string | null; - insurerName?: string | null; - // v1.8.6 — German insurer institution number (IKNR), plaintext. Carried - // here for the FHIR `Coverage` payor Organization identifier. Optional - // so pre-v1.8.6 fixtures still typecheck. - insurerIkNumber?: string | null; - }; - /** - * Free-text practice / clinic name printed on the cover. `null` omits the - * line entirely. Persisted as a user preference (see - * `User.lastReportPracticeName`). - */ - practiceName: string | null; - measurements: Record>; - stats: Record; - glucoseStats: Record; - glucoseRanges: Record; - glucoseUnit: GlucoseUnit; - /** - * v1.17.0 — server-authoritative glucose clinical panel over the report - * period (TIR / GMI / estimated A1C / CV% + the advanced J-index + LBGI/HBGI - * tier), computed by `computeGlucoseClinicalMetrics` so the PDF/FHIR export - * carries the SAME numbers the insights panel and the coach show. Values are - * canonical mg/dL; `glucoseUnit` drives display conversion at render. Carried - * as a spot-reading estimate (`isSpotEstimate: true`), gated by - * `stillLearning` when the period is too thin to assert clinically. - */ - glucoseClinical: GlucoseClinicalMetrics; - bmi: number | null; - compliance: Record; - medications: Array<{ - name: string; - dose: string; - // v1.9.0 — optional user/clinician-asserted drug-classification - // codes. Carried here so the FHIR exporter can emit a coded - // `medicationCodeableConcept` (ATC primary, RxNorm secondary). NULL - // when no code was captured — the exporter then emits the pre-v1.9.0 - // text-only concept. Optional in the type so pre-v1.9.0 fixtures - // still typecheck. - atcCode?: string | null; - rxNormCode?: string | null; - schedules: Array<{ - windowStart: string; - windowEnd: string; - label: string | null; - }>; - }>; - /** - * v1.9.0 — acted medication-intake events over the report window, fed - * to the FHIR `MedicationAdministration` builder. One entry per intake - * row the user actually actioned (taken OR explicitly skipped); - * pending / missed / soft-deleted rows are excluded upstream so the - * exporter never asserts an administration that did not happen. The - * `dose` is the medication's structured dose-in-effect at - * `effectiveAt` (from `MedicationDoseChange`) when one exists, else - * NULL. Optional so pre-v1.9.0 fixtures still typecheck. - */ - medicationAdministrations?: Array<{ - medicationName: string; - /** ISO timestamp: `takenAt` for a taken dose, `scheduledFor` for a skip. */ - effectiveAt: string; - status: "completed" | "not-done"; - /** Free-text dose label (the medication's `dose`), mirrors the statement. */ - doseText: string | null; - /** Structured dose-in-effect at `effectiveAt`, when a dose-change history exists. */ - dose: { value: number; unit: string } | null; - /** Injection site, when the row recorded one. */ - injectionSite: string | null; - /** ATC / RxNorm codes, mirrored from the medication for the self-describing concept. */ - atcCode: string | null; - rxNormCode: string | null; - /** Delivery form — ORAL / INJECTION / OTHER — for the route mapping. */ - deliveryForm: string | null; - }>; - /** - * v1.9.0 — set when the administration cap (see - * {@link resolveMaxMedicationAdministrations}) trimmed the administration - * set. `total` is the full acted-intake count over the - * window; `included` is how many (the most-recent) survived the cap. - * Null/absent when nothing was trimmed. The FHIR builder discloses - * this in the document narrative so the export is honest about the - * omitted (oldest) rows. Optional so pre-v1.9.0 fixtures still typecheck. - */ - medicationAdministrationsTruncation?: { - total: number; - included: number; - } | null; - mood: DoctorReportMood | null; - /** - * v1.4.25 W4d — GLP-1 therapy section. Populated when the user has at - * least one active GLP-1 medication. Null/absent when the user has no - * GLP-1 prescription (the vast majority of accounts). When set the - * PDF gains a "GLP-1 Therapy" section listing dose history, weight - * change over the therapy window, side-effect counts, and compliance. - * Optional in the type so legacy callers + test fixtures that omit - * the field continue to work; the renderer treats absent and null - * identically. - */ - glp1?: { - medications: Array<{ - name: string; - currentDose: { value: number; unit: string; since: string } | null; - doseHistory: Array<{ - value: number; - unit: string; - effectiveFrom: string; - note: string | null; - }>; - lastInjection: { date: string; site: string | null } | null; - compliance: { taken: number; total: number }; - }>; - weightDeltaKg: number | null; - weightStartKg: number | null; - weightEndKg: number | null; - sideEffects: Array<{ tag: string; count: number }>; - } | null; - /** - * v1.10.0 — the server-derived nightly wellness scores - * (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE), summarised over the - * report window. These are 0–100 DESCRIPTIVE composites, NOT clinical - * vitals — they are rendered in a clearly-labelled "Wellness summary" - * section separate from the clinical vitals table, each with a - * "descriptive, not a clinical assessment" note. Null/absent when the - * user has no computed scores in the window (the renderer + FHIR builder - * then skip the section). Optional so pre-v1.10 fixtures still typecheck. - */ - wellnessScores?: Array<{ - /** The score MeasurementType (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE). */ - type: string; - latest: number; - avg: number; - min: number; - max: number; - count: number; - /** ISO timestamp of the latest score. */ - latestAt: string; - }> | null; - /** - * v1.15.0 — concise menstrual-cycle summary (LMP, recent cycles, avg - * length + variability, current phase). Populated ONLY when the `cycle` - * section toggle is explicitly ON (privacy default OFF — a user sharing a - * BP report with a cardiologist must not auto-leak reproductive data) AND - * the user has at least one observed cycle. Statistics only — no free-text - * notes ever reach this surface. Null/absent otherwise; both the PDF and - * FHIR builders then skip the section. Optional so pre-v1.15 fixtures - * still typecheck. - */ - cycle?: import("@/lib/cycle/export-data").CycleExportSummary | null; - /** - * v1.17.1 — structured lab results recorded over the report window. - * Populated when the `labs` section toggle is ON (default) and the user - * recorded at least one result in the period. Statistics + the single - * latest reading per analyte; free-text notes never reach this surface. - * Each entry carries the lab's reference bounds so the PDF can print an - * in/out-of-range marker and the FHIR exporter can emit `referenceRange`. - * Null/absent otherwise; both the PDF and FHIR builders skip the section. - * Optional so pre-v1.17.1 fixtures still typecheck. - */ - labResults?: Array<{ - /** Optional grouping label (e.g. "Großes Blutbild"); null = standalone. */ - panel: string | null; - /** Biomarker name as the user recorded it. */ - analyte: string; - /** - * Latest numeric value for this analyte over the window; null for a - * qualitative reading (see `valueText`). v1.18.9. - */ - value: number | null; - /** - * v1.18.9 — qualitative result text ("negativ" / "positiv" / …); null for a - * numeric reading. Exactly one of `value` / `valueText` is set. - */ - valueText: string | null; - unit: string; - referenceLow: number | null; - referenceHigh: number | null; - /** ISO timestamp of the latest reading. */ - takenAt: string; - /** Count of readings for this analyte in the window (≥ 1). */ - count: number; - }> | null; - /** - * v1.18.1 P4 — illness / condition episodes overlapping the report window. - * Populated ONLY when the `illness` module is enabled (default-on, opt-out); - * the journal is on and shared deliberately, so the privacy stance matches - * labs (ON when the module is on), not mood / cycle. - * Statistics + labels only — the free-text note is NEVER read here, so no - * encrypted plaintext can leak into the report. Each entry feeds a FHIR - * `Condition` (+ `Encounter`) and a PDF table row. Null/absent when the - * module is off or no episode overlaps the window; both builders then skip - * the section. Optional so pre-v1.18.1 fixtures still typecheck. - */ - illnessEpisodes?: Array<{ - /** User-facing condition label (e.g. "Erkältung"). */ - label: string; - /** Broad condition class (INFECTION / ALLERGY / INJURY / …). */ - type: string; - /** Lifecycle (ACUTE / CHRONIC_ONGOING / RECURRING / FLARE). */ - lifecycle: string; - /** ISO onset instant. */ - onsetAt: string; - /** ISO resolution instant, or null while ongoing. */ - resolvedAt: string | null; - }> | null; - /** - * v1.27.x — structured allergy / intolerance records. Reference data, - * not time-windowed: populated whenever the `allergies` section toggle - * is ON (default) and the user recorded at least one live row. The - * reaction description is decrypted fail-soft per row (a key-rotation - * gap omits the value, never fails the report); the free-text note is - * NEVER read here — same stance as the illness journal. Null/absent - * otherwise; the PDF builder then skips the section. Optional so older - * fixtures still typecheck. - */ - allergies?: Array<{ - /** Substance / allergen label as the user recorded it. */ - substance: string; - /** Category (FOOD / MEDICATION / ENVIRONMENT / BIOLOGIC / OTHER). */ - category: string; - /** Kind (ALLERGY / INTOLERANCE). */ - type: string; - /** Worst observed severity (NONE / MILD / MODERATE / SEVERE), or null. */ - severity: string | null; - /** Status (ACTIVE / INACTIVE / RESOLVED). */ - status: string; - /** Decrypted reaction description, or null (genuinely unset). */ - reaction: string | null; - /** - * True when a reaction description WAS stored but could not be decrypted - * (a key-rotation gap / GCM corruption). Distinct from `reaction: null` - * (nothing recorded) so the clinician-facing report shows an honest - * "unreadable" marker rather than a blank that reads as "no reaction". - */ - reactionUnreadable?: boolean; - }> | null; - /** - * v1.27.x — structured family-history records. Reference data, not - * time-windowed: populated whenever the `familyHistory` section toggle - * is ON (default) and the user recorded at least one live row. Labels + - * onset age only — the free-text note is never read here. Null/absent - * otherwise; the PDF builder then skips the section. Optional so older - * fixtures still typecheck. - */ - familyHistory?: Array<{ - /** Relationship (MOTHER / FATHER / SISTER / …). */ - relationship: string; - /** Condition label as the user recorded it. */ - condition: string; - /** The relative's age at onset (years), or null when unknown. */ - ageAtOnset: number | null; - }> | null; -} - -/** The three persisted score types surfaced in the wellness summary. */ -export const WELLNESS_SCORE_REPORT_TYPES = [ - "RECOVERY_SCORE", - "STRESS_SCORE", - "STRAIN_SCORE", -] as const; - -/** One wellness-summary row (latest + range over the report window). */ -interface WellnessScoreSummary { - type: string; - latest: number; - avg: number; - min: number; - max: number; - count: number; - latestAt: string; -} - -/** A measurement row as the doctor-report aggregator reads it (subset). */ -interface RecoveryMeasurementRow { - type: string; - value: number; - measuredAt: Date; - source: MeasurementSource; -} - -/** Minimal Measurement shape the canonical-source collapse consults. */ -export interface CanonicalCollapseRow { - type: string; - value: number; - measuredAt: Date; - source: MeasurementSource; - deviceType?: string | null; -} - -/** - * Collapse a window's raw Measurement rows to one canonical source per metric - * before the doctor report computes per-type avg/min/max. Without this the PDF - * + FHIR stats blend every source (a WHOOP + Apple-Watch resting-heart-rate - * day would average two readings), while the dashboard / insights aggregator - * already collapse to the ladder-canonical source per day. This routes the rows - * through the SAME picker (`pickCanonicalSourceRows`, keyed by - * `metricKeyForType` + the user's day key + source-priority ladder), so the two - * surfaces report identical numbers. - * - * SLEEP_DURATION is passed through untouched: it carries per-stage rows that - * `reconstructSleepNights` resolves with its own source de-dup downstream, so a - * pre-collapse here would drop stages the night reconstruction needs. A type - * with no ladder, or a day with a single source, passes through unchanged via - * the picker's documented fallback. Exported for unit tests. - */ -export function collapseMeasurementsToCanonical( - measurements: readonly T[], - timezone: string, - sourcePriorityJson: unknown, -): T[] { - const dayKey = (d: Date) => userDayKey(d, timezone); - const byType = new Map(); - for (const m of measurements) { - const slot = byType.get(m.type); - if (slot) slot.push(m); - else byType.set(m.type, [m]); - } - const out: T[] = []; - for (const [type, rows] of byType) { - const metricKey = - type === "SLEEP_DURATION" - ? null - : metricKeyForType(type as MeasurementType); - if (!metricKey) { - // Loop, not `push(...rows)` — a sample-dense type's window rows run to - // six figures and a spread call overflows the stack (v1.28.22 class). - for (const r of rows) out.push(r); - continue; - } - const { canonicalRows } = pickCanonicalSourceRows( - rows.map((m) => ({ - ...m, - type: m.type as MeasurementType, - })), - metricKey, - sourcePriorityJson, - dayKey, - ); - // `pickCanonicalSourceRows` returns the spread copies; re-key back to the - // original rows by reference identity is unnecessary because the spread - // preserves every field the caller reads (value / measuredAt / type). - for (const r of canonicalRows as unknown as T[]) out.push(r); - } - return out; -} - -/** - * Loop-based min/max over a value array. NEVER `Math.min(...values)` here: the - * report window's per-type rows are raw measurements, and a sample-dense type - * (per-sample heart rate, CGM glucose) runs to six figures over a year — a - * spread call overflows the stack exactly on the heaviest accounts (the same - * failure class as the Google Health fullSync tracker, fixed v1.28.22). - */ -function minMaxOf(values: readonly number[]): { min: number; max: number } { - let min = Number.POSITIVE_INFINITY; - let max = Number.NEGATIVE_INFINITY; - for (const v of values) { - if (v < min) min = v; - if (v > max) max = v; - } - return { min, max }; -} - -/** - * Summarise RECOVERY_SCORE over the window from the CANONICAL row per day: - * a WHOOP-native row wins over the COMPUTED proxy for the same day, so the - * doctor PDF never blends the proxy and the native value into one min/avg/max. - * Returns null when no recovery row exists. Exported for unit tests. - */ -export function summariseCanonicalRecovery( - measurements: readonly RecoveryMeasurementRow[], - timezone?: string | null, -): WellnessScoreSummary | null { - const recovery = measurements.filter((m) => m.type === "RECOVERY_SCORE"); - if (recovery.length === 0) return null; - const canonical = resolveCanonicalRecovery( - recovery.map((m) => ({ - value: m.value, - measuredAt: m.measuredAt, - source: m.source, - })), - timezone, - ); - if (canonical.length === 0) return null; - // `resolveCanonicalRecovery` returns rows newest-first; the report wants the - // latest value + the window range over the canonical set. - const values = canonical.map((r) => r.value); - const latestRow = canonical[0]; - return { - type: "RECOVERY_SCORE", - latest: Math.round(latestRow.value), - avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length), - min: Math.round(minMaxOf(values).min), - max: Math.round(minMaxOf(values).max), - count: values.length, - latestAt: latestRow.measuredAt.toISOString(), - }; -} +export * from "./doctor-report-types"; +export * from "./doctor-report-helpers"; const GLUCOSE_CONTEXTS: GlucoseContext[] = [ "FASTING", @@ -524,300 +64,6 @@ const GLUCOSE_CONTEXTS: GlucoseContext[] = [ "BEDTIME", ]; -/** - * Validate and normalise the requested reporting window. - * Accepts an unknown value (typically `body.days`); falls back to 90. - */ -export function normaliseDays(value: unknown, fallback = 90): number { - if ( - typeof value === "number" && - Number.isInteger(value) && - value >= 1 && - value <= 365 - ) { - return value; - } - return fallback; -} - -const MIN_RANGE_DAYS = 1; -/** - * Maximum span between `startDate` and `endDate`. Two years balances the - * "give me everything I have" case (chronic condition follow-up) against - * unbounded server work; the existing `days` fallback is capped at 365 so a - * caller asking for a 730-day window must come through the explicit-range - * surface and proves they want it. - */ -const MAX_RANGE_DAYS = 730; -const DEFAULT_RANGE_DAYS = 90; - -/** - * Default ceiling on the number of `MedicationAdministration` source rows - * materialised per export, and the bounds an operator override is clamped - * to. The report window is already bounded at {@link MAX_RANGE_DAYS} - * (730 days), but a chronic medication dosed several times a day across - * that window — multiplied by several such medications — can still produce - * thousands of administration resources in a single Bundle. The default of - * 5 000 covers ~3.5 years at four doses a day, so it effectively never bites - * on a realistic report period while still bounding a pathological export. - */ -const DEFAULT_MAX_MEDICATION_ADMINISTRATIONS = 5000; -const MIN_MEDICATION_ADMINISTRATIONS = 1; -const MAX_MEDICATION_ADMINISTRATIONS_CEILING = 50000; - -/** - * Resolve the administration ceiling from `FHIR_MAX_MEDICATION_ADMINISTRATIONS`. - * Accepts a positive integer within `[1, 50000]`; any unset / non-integer / - * out-of-range value falls back to the default. Exported as a pure function so - * the resolution can be tested without import-time env coupling. - */ -export function resolveMaxMedicationAdministrations( - raw: string | undefined, -): number { - const parsed = Number(raw); - return Number.isInteger(parsed) && - parsed >= MIN_MEDICATION_ADMINISTRATIONS && - parsed <= MAX_MEDICATION_ADMINISTRATIONS_CEILING - ? parsed - : DEFAULT_MAX_MEDICATION_ADMINISTRATIONS; -} - -export interface DoctorReportRange { - /** Start of the reporting window (UTC, inclusive). */ - start: Date; - /** End of the reporting window (UTC, inclusive). */ - end: Date; - /** Inclusive day-count between `start` and `end` (rounded up). */ - days: number; -} - -/** Hard cap on the printed cover line — protects PDF layout from runaway input. */ -const PRACTICE_NAME_MAX_LENGTH = 120; - -/** - * Trim, collapse whitespace, drop control characters, and length-cap a - * caller-provided practice name. Returns `null` if the result is empty so - * the cover line is omitted entirely (rather than rendering a blank label). - */ -export function sanitisePracticeName(value: unknown): string | null { - if (typeof value !== "string") return null; - // Strip ASCII C0 + DEL controls (0x00..0x1F + 0x7F) which jsPDF can't - // render and which break PDF text streams. Then collapse whitespace + trim. - const cleaned = value - .replace(/[\x00-\x1F\x7F]/g, " ") - .replace(/\s+/g, " ") - .trim(); - if (cleaned.length === 0) return null; - return cleaned.slice(0, PRACTICE_NAME_MAX_LENGTH); -} - -function parseIsoDate(value: unknown): Date | null { - if (typeof value !== "string" || value.length === 0) return null; - const d = new Date(value); - if (Number.isNaN(d.getTime())) return null; - return d; -} - -/** - * Turn an arbitrary `{ startDate?, endDate?, days? }` payload into a validated - * inclusive reporting window. - * - * Resolution order: - * 1. Both `startDate` AND `endDate` parse as valid ISO timestamps with - * `endDate >= startDate` and span <= 730 days → use them. - * 2. Neither/invalid range, but a valid `days` integer (1..365) is provided - * → fall back to "last `days` days ending now". - * 3. Otherwise fall back to "last 90 days ending now" (the v1.4.14 default). - * - * The function is intentionally tolerant: invalid input never throws — bad - * shapes silently fall through to the next tier so a malformed request - * still produces a useful report rather than a 422. - */ -export function normaliseDateRange( - value: unknown, - now: Date = new Date(), -): DoctorReportRange { - const body = - typeof value === "object" && value !== null - ? (value as Record) - : {}; - const startCandidate = parseIsoDate(body.startDate); - const endCandidate = parseIsoDate(body.endDate); - - if (startCandidate && endCandidate) { - const startMs = startCandidate.getTime(); - const endMs = endCandidate.getTime(); - if (endMs >= startMs) { - const spanMs = endMs - startMs; - const spanDays = Math.ceil(spanMs / 86_400_000) || 1; - if (spanDays >= MIN_RANGE_DAYS && spanDays <= MAX_RANGE_DAYS) { - return { - start: startCandidate, - end: endCandidate, - days: spanDays, - }; - } - } - } - - const days = normaliseDays(body.days, DEFAULT_RANGE_DAYS); - const end = now; - const start = new Date(end); - start.setDate(start.getDate() - days); - return { start, end, days }; -} - -export interface CollectDoctorReportOptions { - /** Free-text practice/clinic name to print on the cover. Optional. */ - practiceName?: string | null; - /** - * v1.4.25 W6c — per-section visibility toggles. When omitted the - * documented defaults apply (every section ON except mood). When - * `sections.mood = false` the aggregator does NOT query - * `MoodEntry` at all — the mood data never leaves the DB row - * (privacy-by-default). Other sections are stripped from the - * returned payload so downstream renderers (PDF + JSON consumer) - * don't accidentally print a section the user opted out of. - */ - sections?: Partial; - /** - * v1.18.0 — resolved per-user module enable/disable map. When omitted the - * aggregator resolves it itself via `resolveModuleMap(userId)`. A disabled - * module forces its report section / FHIR resources out of the payload, - * regardless of the user's per-report `sections` toggles: a user who turned - * off the Sleep module exports no sleep series, mood Observations, glucose - * panel, recovery/strain scores, cycle summary, or lab results for the - * modules they no longer keep. Core clinical sections (weight / BP / pulse / - * medications) carry no module key and are always included. Injectable for - * tests so the module ↔ section mapping can be asserted without a DB. - */ - moduleMap?: Record; -} - -/** - * v1.17 W1a — the medication row shape the ledger compliance builder needs. - * Carries the cadence-engine context fields (course window + creation + - * archived schedule eras) so the same band minter the detail page uses can - * reconstruct expected slots. - */ -export interface DoctorReportComplianceMedication { - id: string; - name: string; - asNeeded: boolean; - startsOn: Date | null; - endsOn: Date | null; - oneShot: boolean; - createdAt: Date; - schedules: ComplianceSchedule[]; - scheduleRevisions?: ScheduleRevisionLike[]; - /** v1.25 H-MED1 — pause eras so paused days drop out of the denominator. */ - pauseEras?: MedicationPauseEraLike[]; -} - -/** A window-bounded intake row keyed to its medication. */ -export interface DoctorReportComplianceIntake extends IntakeEvent { - medicationId: string; -} - -/** - * v1.17 W1a — doctor-report medication adherence through the dose-ledger - * authority. - * - * The previous implementation tallied RAW intake rows: `taken = takenAt != null`, - * else `skipped`, else `missed`, with the denominator being every row. That - * double-counted cross-source duplicate rows (a REMINDER row + an API row for - * the same slot both counted as taken), ignored band timing (a pre-window or - * ad-hoc take counted as a plain "taken", a pending row counted as "missed" - * regardless of the miss cutoff), and honoured no cadence (rolling / cyclic / - * RRULE off-weeks could enter the tally). A clinician comparing the PDF % to - * the app detail page could see two different adherence numbers for one drug. - * - * Route the report through the SAME ledger the detail page uses: per - * medication, mint the cadence-aware bands over the report window - * `[start, end]` and tally `taken` (on-time + late), `missed` and `skipped` - * from the unified dose-history ledger. `total = taken + missed` so the - * renderers' `taken / total` rate equals the ledger rate the detail page - * shows. PRN / as-needed medications are excluded exactly as before (no - * schedule, no expected dose — a fabricated 100 % on a clinical report). - * - * Pure / synchronous — bands come from pre-fetched schedules + intake - * instants. Keyed by medication name to match the renderer contract. - */ -export function buildLedgerCompliance( - medications: DoctorReportComplianceMedication[], - intakeEvents: DoctorReportComplianceIntake[], - userTz: string, - start: Date, - end: Date, - now: Date, -): Record { - const eventsByMedId = new Map(); - for (const event of intakeEvents) { - const list = eventsByMedId.get(event.medicationId); - if (list) list.push(event); - else eventsByMedId.set(event.medicationId, [event]); - } - - const compliance: Record = {}; - for (const med of medications) { - // PRN / as-needed carry no schedule and no expected dose — excluded so - // the report never prints a fabricated 100 %. - if (med.asNeeded) continue; - if (med.schedules.length === 0) continue; - - const events = eventsByMedId.get(med.id) ?? []; - const ctx = buildComplianceMedicationContext( - med, - lastNonSkippedTakenAt(events), - userTz, - ); - const tally = tallyComplianceFromLedger( - events, - med.schedules, - ctx, - start, - end, - now, - ); - // `total = taken + missed` (the ledger denominator) so the renderer's - // `taken / total` equals `tally.rate` — the exact number the detail page - // shows. Deliberate skips are reported separately but stay out of `total`. - compliance[med.name] = { - total: tally.denominator, - taken: tally.taken, - skipped: tally.skipped, - missed: tally.missed, - }; - } - return compliance; -} - -/** - * Decrypt an allergy reaction envelope for a clinician-facing export, keeping - * "unreadable" distinct from "unset". A genuinely-empty envelope yields - * `{ reaction: null, reactionUnreadable: false }`; a stored-but-undecryptable - * one (key-rotation gap / GCM corruption) yields - * `{ reaction: null, reactionUnreadable: true }` so the report can render an - * honest marker instead of a blank that reads as "no reaction recorded". Pure - * (no logging) so it is unit-testable; the caller logs on the unreadable flag. - */ -export function decryptAllergyReaction(reactionEncrypted: Uint8Array | null): { - reaction: string | null; - reactionUnreadable: boolean; -} { - if (!reactionEncrypted || reactionEncrypted.byteLength === 0) { - return { reaction: null, reactionUnreadable: false }; - } - try { - return { - reaction: decryptFromBytes(reactionEncrypted), - reactionUnreadable: false, - }; - } catch { - return { reaction: null, reactionUnreadable: true }; - } -} - /** * Aggregate the doctor-report payload for a user over a `[start, end]` range. * Pure data assembly — no auth, no rate-limit, no audit. Idempotent. diff --git a/src/lib/doctor-report-helpers.ts b/src/lib/doctor-report-helpers.ts new file mode 100644 index 000000000..c17e4aa6e --- /dev/null +++ b/src/lib/doctor-report-helpers.ts @@ -0,0 +1,421 @@ +/** + * Doctor-report pure helpers. + * + * The synchronous, DB-free functions behind the doctor-report aggregator: + * window normalisation, canonical-source collapse, recovery summarisation, + * ledger-based compliance tallying, and the export-surface sanitisers. Split + * out of `doctor-report-data.ts` as pure code motion; that module re-exports + * every public name here, so existing call sites keep importing from it + * unchanged. The data shapes live in `doctor-report-types.ts`; the aggregator + * itself stays in `doctor-report-data.ts`. + */ + +import type { + MeasurementSource, + MeasurementType, +} from "@/generated/prisma/client"; +import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; +import { userDayKey } from "@/lib/tz/resolver"; +import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; +import { + buildComplianceMedicationContext, + lastNonSkippedTakenAt, + tallyComplianceFromLedger, +} from "@/lib/analytics/compliance"; +import type { + CanonicalCollapseRow, + DoctorReportCompliance, + DoctorReportComplianceIntake, + DoctorReportComplianceMedication, + DoctorReportRange, +} from "./doctor-report-types"; + +/** + * Canonical adherence-rate rounding for the doctor-report export surfaces. + * + * The PDF table, the PDF clinical-summary headline, the FHIR adherence + * Observation, and the GLP-1 block all derive the rate from the SAME ledger + * denominator (`taken / total`, `total = taken + missed`). They MUST round it + * the same way the app does so a clinician comparing the export to the in-app + * card never sees a presentational divergence (87 % on the card, 87.3 % on the + * PDF). The app convention is `round(100 · taken / denominator)` capped at 100 + * (see `dose-history-ledger-compute.ts` + `tallyComplianceFromLedger`) — an + * integer percent. This is the one source of truth for every export surface. + * + * Returns `null` when `total <= 0` so callers render the "no expected dose" + * placeholder instead of a misleading `0` or `100`. + */ +export function adherenceRatePercent( + taken: number, + total: number, +): number | null { + if (total <= 0) return null; + return Math.min(100, Math.round((taken / total) * 100)); +} + +/** One wellness-summary row (latest + range over the report window). */ +interface WellnessScoreSummary { + type: string; + latest: number; + avg: number; + min: number; + max: number; + count: number; + latestAt: string; +} + +/** A measurement row as the doctor-report aggregator reads it (subset). */ +interface RecoveryMeasurementRow { + type: string; + value: number; + measuredAt: Date; + source: MeasurementSource; +} + +/** + * Collapse a window's raw Measurement rows to one canonical source per metric + * before the doctor report computes per-type avg/min/max. Without this the PDF + * + FHIR stats blend every source (a WHOOP + Apple-Watch resting-heart-rate + * day would average two readings), while the dashboard / insights aggregator + * already collapse to the ladder-canonical source per day. This routes the rows + * through the SAME picker (`pickCanonicalSourceRows`, keyed by + * `metricKeyForType` + the user's day key + source-priority ladder), so the two + * surfaces report identical numbers. + * + * SLEEP_DURATION is passed through untouched: it carries per-stage rows that + * `reconstructSleepNights` resolves with its own source de-dup downstream, so a + * pre-collapse here would drop stages the night reconstruction needs. A type + * with no ladder, or a day with a single source, passes through unchanged via + * the picker's documented fallback. Exported for unit tests. + */ +export function collapseMeasurementsToCanonical( + measurements: readonly T[], + timezone: string, + sourcePriorityJson: unknown, +): T[] { + const dayKey = (d: Date) => userDayKey(d, timezone); + const byType = new Map(); + for (const m of measurements) { + const slot = byType.get(m.type); + if (slot) slot.push(m); + else byType.set(m.type, [m]); + } + const out: T[] = []; + for (const [type, rows] of byType) { + const metricKey = + type === "SLEEP_DURATION" + ? null + : metricKeyForType(type as MeasurementType); + if (!metricKey) { + // Loop, not `push(...rows)` — a sample-dense type's window rows run to + // six figures and a spread call overflows the stack (v1.28.22 class). + for (const r of rows) out.push(r); + continue; + } + const { canonicalRows } = pickCanonicalSourceRows( + rows.map((m) => ({ + ...m, + type: m.type as MeasurementType, + })), + metricKey, + sourcePriorityJson, + dayKey, + ); + // `pickCanonicalSourceRows` returns the spread copies; re-key back to the + // original rows by reference identity is unnecessary because the spread + // preserves every field the caller reads (value / measuredAt / type). + for (const r of canonicalRows as unknown as T[]) out.push(r); + } + return out; +} + +/** + * Loop-based min/max over a value array. NEVER `Math.min(...values)` here: the + * report window's per-type rows are raw measurements, and a sample-dense type + * (per-sample heart rate, CGM glucose) runs to six figures over a year — a + * spread call overflows the stack exactly on the heaviest accounts (the same + * failure class as the Google Health fullSync tracker, fixed v1.28.22). + */ +export function minMaxOf(values: readonly number[]): { + min: number; + max: number; +} { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + for (const v of values) { + if (v < min) min = v; + if (v > max) max = v; + } + return { min, max }; +} + +/** + * Summarise RECOVERY_SCORE over the window from the CANONICAL row per day: + * a WHOOP-native row wins over the COMPUTED proxy for the same day, so the + * doctor PDF never blends the proxy and the native value into one min/avg/max. + * Returns null when no recovery row exists. Exported for unit tests. + */ +export function summariseCanonicalRecovery( + measurements: readonly RecoveryMeasurementRow[], + timezone?: string | null, +): WellnessScoreSummary | null { + const recovery = measurements.filter((m) => m.type === "RECOVERY_SCORE"); + if (recovery.length === 0) return null; + const canonical = resolveCanonicalRecovery( + recovery.map((m) => ({ + value: m.value, + measuredAt: m.measuredAt, + source: m.source, + })), + timezone, + ); + if (canonical.length === 0) return null; + // `resolveCanonicalRecovery` returns rows newest-first; the report wants the + // latest value + the window range over the canonical set. + const values = canonical.map((r) => r.value); + const latestRow = canonical[0]; + return { + type: "RECOVERY_SCORE", + latest: Math.round(latestRow.value), + avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length), + min: Math.round(minMaxOf(values).min), + max: Math.round(minMaxOf(values).max), + count: values.length, + latestAt: latestRow.measuredAt.toISOString(), + }; +} + +/** + * Validate and normalise the requested reporting window. + * Accepts an unknown value (typically `body.days`); falls back to 90. + */ +export function normaliseDays(value: unknown, fallback = 90): number { + if ( + typeof value === "number" && + Number.isInteger(value) && + value >= 1 && + value <= 365 + ) { + return value; + } + return fallback; +} + +const MIN_RANGE_DAYS = 1; +/** + * Maximum span between `startDate` and `endDate`. Two years balances the + * "give me everything I have" case (chronic condition follow-up) against + * unbounded server work; the existing `days` fallback is capped at 365 so a + * caller asking for a 730-day window must come through the explicit-range + * surface and proves they want it. + */ +const MAX_RANGE_DAYS = 730; +const DEFAULT_RANGE_DAYS = 90; + +/** + * Default ceiling on the number of `MedicationAdministration` source rows + * materialised per export, and the bounds an operator override is clamped + * to. The report window is already bounded at {@link MAX_RANGE_DAYS} + * (730 days), but a chronic medication dosed several times a day across + * that window — multiplied by several such medications — can still produce + * thousands of administration resources in a single Bundle. The default of + * 5 000 covers ~3.5 years at four doses a day, so it effectively never bites + * on a realistic report period while still bounding a pathological export. + */ +const DEFAULT_MAX_MEDICATION_ADMINISTRATIONS = 5000; +const MIN_MEDICATION_ADMINISTRATIONS = 1; +const MAX_MEDICATION_ADMINISTRATIONS_CEILING = 50000; + +/** + * Resolve the administration ceiling from `FHIR_MAX_MEDICATION_ADMINISTRATIONS`. + * Accepts a positive integer within `[1, 50000]`; any unset / non-integer / + * out-of-range value falls back to the default. Exported as a pure function so + * the resolution can be tested without import-time env coupling. + */ +export function resolveMaxMedicationAdministrations( + raw: string | undefined, +): number { + const parsed = Number(raw); + return Number.isInteger(parsed) && + parsed >= MIN_MEDICATION_ADMINISTRATIONS && + parsed <= MAX_MEDICATION_ADMINISTRATIONS_CEILING + ? parsed + : DEFAULT_MAX_MEDICATION_ADMINISTRATIONS; +} + +/** Hard cap on the printed cover line — protects PDF layout from runaway input. */ +const PRACTICE_NAME_MAX_LENGTH = 120; + +/** + * Trim, collapse whitespace, drop control characters, and length-cap a + * caller-provided practice name. Returns `null` if the result is empty so + * the cover line is omitted entirely (rather than rendering a blank label). + */ +export function sanitisePracticeName(value: unknown): string | null { + if (typeof value !== "string") return null; + // Strip ASCII C0 + DEL controls (0x00..0x1F + 0x7F) which jsPDF can't + // render and which break PDF text streams. Then collapse whitespace + trim. + const cleaned = value + .replace(/[\x00-\x1F\x7F]/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (cleaned.length === 0) return null; + return cleaned.slice(0, PRACTICE_NAME_MAX_LENGTH); +} + +function parseIsoDate(value: unknown): Date | null { + if (typeof value !== "string" || value.length === 0) return null; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return null; + return d; +} + +/** + * Turn an arbitrary `{ startDate?, endDate?, days? }` payload into a validated + * inclusive reporting window. + * + * Resolution order: + * 1. Both `startDate` AND `endDate` parse as valid ISO timestamps with + * `endDate >= startDate` and span <= 730 days → use them. + * 2. Neither/invalid range, but a valid `days` integer (1..365) is provided + * → fall back to "last `days` days ending now". + * 3. Otherwise fall back to "last 90 days ending now" (the v1.4.14 default). + * + * The function is intentionally tolerant: invalid input never throws — bad + * shapes silently fall through to the next tier so a malformed request + * still produces a useful report rather than a 422. + */ +export function normaliseDateRange( + value: unknown, + now: Date = new Date(), +): DoctorReportRange { + const body = + typeof value === "object" && value !== null + ? (value as Record) + : {}; + const startCandidate = parseIsoDate(body.startDate); + const endCandidate = parseIsoDate(body.endDate); + + if (startCandidate && endCandidate) { + const startMs = startCandidate.getTime(); + const endMs = endCandidate.getTime(); + if (endMs >= startMs) { + const spanMs = endMs - startMs; + const spanDays = Math.ceil(spanMs / 86_400_000) || 1; + if (spanDays >= MIN_RANGE_DAYS && spanDays <= MAX_RANGE_DAYS) { + return { + start: startCandidate, + end: endCandidate, + days: spanDays, + }; + } + } + } + + const days = normaliseDays(body.days, DEFAULT_RANGE_DAYS); + const end = now; + const start = new Date(end); + start.setDate(start.getDate() - days); + return { start, end, days }; +} + +/** + * v1.17 W1a — doctor-report medication adherence through the dose-ledger + * authority. + * + * The previous implementation tallied RAW intake rows: `taken = takenAt != null`, + * else `skipped`, else `missed`, with the denominator being every row. That + * double-counted cross-source duplicate rows (a REMINDER row + an API row for + * the same slot both counted as taken), ignored band timing (a pre-window or + * ad-hoc take counted as a plain "taken", a pending row counted as "missed" + * regardless of the miss cutoff), and honoured no cadence (rolling / cyclic / + * RRULE off-weeks could enter the tally). A clinician comparing the PDF % to + * the app detail page could see two different adherence numbers for one drug. + * + * Route the report through the SAME ledger the detail page uses: per + * medication, mint the cadence-aware bands over the report window + * `[start, end]` and tally `taken` (on-time + late), `missed` and `skipped` + * from the unified dose-history ledger. `total = taken + missed` so the + * renderers' `taken / total` rate equals the ledger rate the detail page + * shows. PRN / as-needed medications are excluded exactly as before (no + * schedule, no expected dose — a fabricated 100 % on a clinical report). + * + * Pure / synchronous — bands come from pre-fetched schedules + intake + * instants. Keyed by medication name to match the renderer contract. + */ +export function buildLedgerCompliance( + medications: DoctorReportComplianceMedication[], + intakeEvents: DoctorReportComplianceIntake[], + userTz: string, + start: Date, + end: Date, + now: Date, +): Record { + const eventsByMedId = new Map(); + for (const event of intakeEvents) { + const list = eventsByMedId.get(event.medicationId); + if (list) list.push(event); + else eventsByMedId.set(event.medicationId, [event]); + } + + const compliance: Record = {}; + for (const med of medications) { + // PRN / as-needed carry no schedule and no expected dose — excluded so + // the report never prints a fabricated 100 %. + if (med.asNeeded) continue; + if (med.schedules.length === 0) continue; + + const events = eventsByMedId.get(med.id) ?? []; + const ctx = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(events), + userTz, + ); + const tally = tallyComplianceFromLedger( + events, + med.schedules, + ctx, + start, + end, + now, + ); + // `total = taken + missed` (the ledger denominator) so the renderer's + // `taken / total` equals `tally.rate` — the exact number the detail page + // shows. Deliberate skips are reported separately but stay out of `total`. + compliance[med.name] = { + total: tally.denominator, + taken: tally.taken, + skipped: tally.skipped, + missed: tally.missed, + }; + } + return compliance; +} + +/** + * Decrypt an allergy reaction envelope for a clinician-facing export, keeping + * "unreadable" distinct from "unset". A genuinely-empty envelope yields + * `{ reaction: null, reactionUnreadable: false }`; a stored-but-undecryptable + * one (key-rotation gap / GCM corruption) yields + * `{ reaction: null, reactionUnreadable: true }` so the report can render an + * honest marker instead of a blank that reads as "no reaction recorded". Pure + * (no logging) so it is unit-testable; the caller logs on the unreadable flag. + */ +export function decryptAllergyReaction(reactionEncrypted: Uint8Array | null): { + reaction: string | null; + reactionUnreadable: boolean; +} { + if (!reactionEncrypted || reactionEncrypted.byteLength === 0) { + return { reaction: null, reactionUnreadable: false }; + } + try { + return { + reaction: decryptFromBytes(reactionEncrypted), + reactionUnreadable: false, + }; + } catch { + return { reaction: null, reactionUnreadable: true }; + } +} diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts new file mode 100644 index 000000000..af5ea2204 --- /dev/null +++ b/src/lib/doctor-report-types.ts @@ -0,0 +1,394 @@ +/** + * Doctor-report data shapes. + * + * The interface / constant inventory for the aggregated doctor-report payload + * (`DoctorReport*` shapes, the wellness-score type list, the canonical-collapse + * row shape, and the collector option contracts). Split out of + * `doctor-report-data.ts` as pure code motion; that module re-exports every + * name here, so existing call sites keep importing from it unchanged. The pure + * helpers live in `doctor-report-helpers.ts`; the aggregator itself stays in + * `doctor-report-data.ts`. + */ + +import type { GlucoseUnit } from "@/lib/glucose"; +import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import type { MeasurementSource } from "@/generated/prisma/client"; +import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs"; +import type { ModuleKey } from "@/lib/modules/gate"; +import type { + ComplianceSchedule, + IntakeEvent, + MedicationPauseEraLike, +} from "@/lib/analytics/compliance"; +import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; + +export interface DoctorReportStats { + avg: number; + min: number; + max: number; + count: number; + latest: number; +} + +export interface DoctorReportCompliance { + total: number; + taken: number; + skipped: number; + missed: number; +} + +export interface DoctorReportMood { + avg: number; + min: number; + max: number; + count: number; + distribution: Record; +} + +export interface DoctorReportData { + /** + * Reporting period bounds. `start` and `end` are ISO timestamps; `days` is + * the inclusive day-count between the two (rounded up) so existing callers + * that displayed "last N days" continue to work without a UI rewrite. + */ + period: { days: number; since: string; start: string; end: string }; + patient: { + username: string | null; + dateOfBirth: string | null; + gender: string | null; + heightCm: number | null; + // v1.7.0 — optional patient-identity fields for the export cover + + // FHIR Patient. `fullName` + `insurerName` are plaintext columns; the + // KVNR is NOT carried here (it's encrypted at rest and decrypted by + // the route, then handed to the builders separately). Optional in the + // type so pre-v1.7.0 fixtures that omit them still typecheck — the + // renderer treats absent and null identically. + fullName?: string | null; + insurerName?: string | null; + // v1.8.6 — German insurer institution number (IKNR), plaintext. Carried + // here for the FHIR `Coverage` payor Organization identifier. Optional + // so pre-v1.8.6 fixtures still typecheck. + insurerIkNumber?: string | null; + }; + /** + * Free-text practice / clinic name printed on the cover. `null` omits the + * line entirely. Persisted as a user preference (see + * `User.lastReportPracticeName`). + */ + practiceName: string | null; + measurements: Record>; + stats: Record; + glucoseStats: Record; + glucoseRanges: Record; + glucoseUnit: GlucoseUnit; + /** + * v1.17.0 — server-authoritative glucose clinical panel over the report + * period (TIR / GMI / estimated A1C / CV% + the advanced J-index + LBGI/HBGI + * tier), computed by `computeGlucoseClinicalMetrics` so the PDF/FHIR export + * carries the SAME numbers the insights panel and the coach show. Values are + * canonical mg/dL; `glucoseUnit` drives display conversion at render. Carried + * as a spot-reading estimate (`isSpotEstimate: true`), gated by + * `stillLearning` when the period is too thin to assert clinically. + */ + glucoseClinical: GlucoseClinicalMetrics; + bmi: number | null; + compliance: Record; + medications: Array<{ + name: string; + dose: string; + // v1.9.0 — optional user/clinician-asserted drug-classification + // codes. Carried here so the FHIR exporter can emit a coded + // `medicationCodeableConcept` (ATC primary, RxNorm secondary). NULL + // when no code was captured — the exporter then emits the pre-v1.9.0 + // text-only concept. Optional in the type so pre-v1.9.0 fixtures + // still typecheck. + atcCode?: string | null; + rxNormCode?: string | null; + schedules: Array<{ + windowStart: string; + windowEnd: string; + label: string | null; + }>; + }>; + /** + * v1.9.0 — acted medication-intake events over the report window, fed + * to the FHIR `MedicationAdministration` builder. One entry per intake + * row the user actually actioned (taken OR explicitly skipped); + * pending / missed / soft-deleted rows are excluded upstream so the + * exporter never asserts an administration that did not happen. The + * `dose` is the medication's structured dose-in-effect at + * `effectiveAt` (from `MedicationDoseChange`) when one exists, else + * NULL. Optional so pre-v1.9.0 fixtures still typecheck. + */ + medicationAdministrations?: Array<{ + medicationName: string; + /** ISO timestamp: `takenAt` for a taken dose, `scheduledFor` for a skip. */ + effectiveAt: string; + status: "completed" | "not-done"; + /** Free-text dose label (the medication's `dose`), mirrors the statement. */ + doseText: string | null; + /** Structured dose-in-effect at `effectiveAt`, when a dose-change history exists. */ + dose: { value: number; unit: string } | null; + /** Injection site, when the row recorded one. */ + injectionSite: string | null; + /** ATC / RxNorm codes, mirrored from the medication for the self-describing concept. */ + atcCode: string | null; + rxNormCode: string | null; + /** Delivery form — ORAL / INJECTION / OTHER — for the route mapping. */ + deliveryForm: string | null; + }>; + /** + * v1.9.0 — set when the administration cap (see + * {@link resolveMaxMedicationAdministrations}) trimmed the administration + * set. `total` is the full acted-intake count over the + * window; `included` is how many (the most-recent) survived the cap. + * Null/absent when nothing was trimmed. The FHIR builder discloses + * this in the document narrative so the export is honest about the + * omitted (oldest) rows. Optional so pre-v1.9.0 fixtures still typecheck. + */ + medicationAdministrationsTruncation?: { + total: number; + included: number; + } | null; + mood: DoctorReportMood | null; + /** + * v1.4.25 W4d — GLP-1 therapy section. Populated when the user has at + * least one active GLP-1 medication. Null/absent when the user has no + * GLP-1 prescription (the vast majority of accounts). When set the + * PDF gains a "GLP-1 Therapy" section listing dose history, weight + * change over the therapy window, side-effect counts, and compliance. + * Optional in the type so legacy callers + test fixtures that omit + * the field continue to work; the renderer treats absent and null + * identically. + */ + glp1?: { + medications: Array<{ + name: string; + currentDose: { value: number; unit: string; since: string } | null; + doseHistory: Array<{ + value: number; + unit: string; + effectiveFrom: string; + note: string | null; + }>; + lastInjection: { date: string; site: string | null } | null; + compliance: { taken: number; total: number }; + }>; + weightDeltaKg: number | null; + weightStartKg: number | null; + weightEndKg: number | null; + sideEffects: Array<{ tag: string; count: number }>; + } | null; + /** + * v1.10.0 — the server-derived nightly wellness scores + * (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE), summarised over the + * report window. These are 0–100 DESCRIPTIVE composites, NOT clinical + * vitals — they are rendered in a clearly-labelled "Wellness summary" + * section separate from the clinical vitals table, each with a + * "descriptive, not a clinical assessment" note. Null/absent when the + * user has no computed scores in the window (the renderer + FHIR builder + * then skip the section). Optional so pre-v1.10 fixtures still typecheck. + */ + wellnessScores?: Array<{ + /** The score MeasurementType (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE). */ + type: string; + latest: number; + avg: number; + min: number; + max: number; + count: number; + /** ISO timestamp of the latest score. */ + latestAt: string; + }> | null; + /** + * v1.15.0 — concise menstrual-cycle summary (LMP, recent cycles, avg + * length + variability, current phase). Populated ONLY when the `cycle` + * section toggle is explicitly ON (privacy default OFF — a user sharing a + * BP report with a cardiologist must not auto-leak reproductive data) AND + * the user has at least one observed cycle. Statistics only — no free-text + * notes ever reach this surface. Null/absent otherwise; both the PDF and + * FHIR builders then skip the section. Optional so pre-v1.15 fixtures + * still typecheck. + */ + cycle?: import("@/lib/cycle/export-data").CycleExportSummary | null; + /** + * v1.17.1 — structured lab results recorded over the report window. + * Populated when the `labs` section toggle is ON (default) and the user + * recorded at least one result in the period. Statistics + the single + * latest reading per analyte; free-text notes never reach this surface. + * Each entry carries the lab's reference bounds so the PDF can print an + * in/out-of-range marker and the FHIR exporter can emit `referenceRange`. + * Null/absent otherwise; both the PDF and FHIR builders skip the section. + * Optional so pre-v1.17.1 fixtures still typecheck. + */ + labResults?: Array<{ + /** Optional grouping label (e.g. "Großes Blutbild"); null = standalone. */ + panel: string | null; + /** Biomarker name as the user recorded it. */ + analyte: string; + /** + * Latest numeric value for this analyte over the window; null for a + * qualitative reading (see `valueText`). v1.18.9. + */ + value: number | null; + /** + * v1.18.9 — qualitative result text ("negativ" / "positiv" / …); null for a + * numeric reading. Exactly one of `value` / `valueText` is set. + */ + valueText: string | null; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + /** ISO timestamp of the latest reading. */ + takenAt: string; + /** Count of readings for this analyte in the window (≥ 1). */ + count: number; + }> | null; + /** + * v1.18.1 P4 — illness / condition episodes overlapping the report window. + * Populated ONLY when the `illness` module is enabled (default-on, opt-out); + * the journal is on and shared deliberately, so the privacy stance matches + * labs (ON when the module is on), not mood / cycle. + * Statistics + labels only — the free-text note is NEVER read here, so no + * encrypted plaintext can leak into the report. Each entry feeds a FHIR + * `Condition` (+ `Encounter`) and a PDF table row. Null/absent when the + * module is off or no episode overlaps the window; both builders then skip + * the section. Optional so pre-v1.18.1 fixtures still typecheck. + */ + illnessEpisodes?: Array<{ + /** User-facing condition label (e.g. "Erkältung"). */ + label: string; + /** Broad condition class (INFECTION / ALLERGY / INJURY / …). */ + type: string; + /** Lifecycle (ACUTE / CHRONIC_ONGOING / RECURRING / FLARE). */ + lifecycle: string; + /** ISO onset instant. */ + onsetAt: string; + /** ISO resolution instant, or null while ongoing. */ + resolvedAt: string | null; + }> | null; + /** + * v1.27.x — structured allergy / intolerance records. Reference data, + * not time-windowed: populated whenever the `allergies` section toggle + * is ON (default) and the user recorded at least one live row. The + * reaction description is decrypted fail-soft per row (a key-rotation + * gap omits the value, never fails the report); the free-text note is + * NEVER read here — same stance as the illness journal. Null/absent + * otherwise; the PDF builder then skips the section. Optional so older + * fixtures still typecheck. + */ + allergies?: Array<{ + /** Substance / allergen label as the user recorded it. */ + substance: string; + /** Category (FOOD / MEDICATION / ENVIRONMENT / BIOLOGIC / OTHER). */ + category: string; + /** Kind (ALLERGY / INTOLERANCE). */ + type: string; + /** Worst observed severity (NONE / MILD / MODERATE / SEVERE), or null. */ + severity: string | null; + /** Status (ACTIVE / INACTIVE / RESOLVED). */ + status: string; + /** Decrypted reaction description, or null (genuinely unset). */ + reaction: string | null; + /** + * True when a reaction description WAS stored but could not be decrypted + * (a key-rotation gap / GCM corruption). Distinct from `reaction: null` + * (nothing recorded) so the clinician-facing report shows an honest + * "unreadable" marker rather than a blank that reads as "no reaction". + */ + reactionUnreadable?: boolean; + }> | null; + /** + * v1.27.x — structured family-history records. Reference data, not + * time-windowed: populated whenever the `familyHistory` section toggle + * is ON (default) and the user recorded at least one live row. Labels + + * onset age only — the free-text note is never read here. Null/absent + * otherwise; the PDF builder then skips the section. Optional so older + * fixtures still typecheck. + */ + familyHistory?: Array<{ + /** Relationship (MOTHER / FATHER / SISTER / …). */ + relationship: string; + /** Condition label as the user recorded it. */ + condition: string; + /** The relative's age at onset (years), or null when unknown. */ + ageAtOnset: number | null; + }> | null; +} + +/** The three persisted score types surfaced in the wellness summary. */ +export const WELLNESS_SCORE_REPORT_TYPES = [ + "RECOVERY_SCORE", + "STRESS_SCORE", + "STRAIN_SCORE", +] as const; + +/** Minimal Measurement shape the canonical-source collapse consults. */ +export interface CanonicalCollapseRow { + type: string; + value: number; + measuredAt: Date; + source: MeasurementSource; + deviceType?: string | null; +} + +export interface DoctorReportRange { + /** Start of the reporting window (UTC, inclusive). */ + start: Date; + /** End of the reporting window (UTC, inclusive). */ + end: Date; + /** Inclusive day-count between `start` and `end` (rounded up). */ + days: number; +} + +export interface CollectDoctorReportOptions { + /** Free-text practice/clinic name to print on the cover. Optional. */ + practiceName?: string | null; + /** + * v1.4.25 W6c — per-section visibility toggles. When omitted the + * documented defaults apply (every section ON except mood). When + * `sections.mood = false` the aggregator does NOT query + * `MoodEntry` at all — the mood data never leaves the DB row + * (privacy-by-default). Other sections are stripped from the + * returned payload so downstream renderers (PDF + JSON consumer) + * don't accidentally print a section the user opted out of. + */ + sections?: Partial; + /** + * v1.18.0 — resolved per-user module enable/disable map. When omitted the + * aggregator resolves it itself via `resolveModuleMap(userId)`. A disabled + * module forces its report section / FHIR resources out of the payload, + * regardless of the user's per-report `sections` toggles: a user who turned + * off the Sleep module exports no sleep series, mood Observations, glucose + * panel, recovery/strain scores, cycle summary, or lab results for the + * modules they no longer keep. Core clinical sections (weight / BP / pulse / + * medications) carry no module key and are always included. Injectable for + * tests so the module ↔ section mapping can be asserted without a DB. + */ + moduleMap?: Record; +} + +/** + * v1.17 W1a — the medication row shape the ledger compliance builder needs. + * Carries the cadence-engine context fields (course window + creation + + * archived schedule eras) so the same band minter the detail page uses can + * reconstruct expected slots. + */ +export interface DoctorReportComplianceMedication { + id: string; + name: string; + asNeeded: boolean; + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + createdAt: Date; + schedules: ComplianceSchedule[]; + scheduleRevisions?: ScheduleRevisionLike[]; + /** v1.25 H-MED1 — pause eras so paused days drop out of the denominator. */ + pauseEras?: MedicationPauseEraLike[]; +} + +/** A window-bounded intake row keyed to its medication. */ +export interface DoctorReportComplianceIntake extends IntakeEvent { + medicationId: string; +} diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index 66d55658f..237500921 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -25,16 +25,30 @@ * Basic auth (RFC 6749 §2.3.1) — Basic + PKCE together. * - `access_type=offline` + `prompt=consent` are required to reliably receive * a refresh token (and force one on every re-consent). + * + * The pure mapping layer (payload → Measurement / Workout shapes) lives in + * `mappers.ts` and is re-exported below, so the sync layer and tests keep a + * single `./client` import surface. The shared wire-shape symbols + * (`GoogleHealthDataType`, `GOOGLE_HEALTH_DATA_TYPES`, the point types) live in + * `mappers.ts` and are imported here type-only — that direction keeps the + * module graph acyclic (`client` → `mappers`, never back). */ import { createHash, randomBytes } from "node:crypto"; import { annotate, getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; -import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; import { GoogleHealthApiError, classifyGoogleHealthResponse, type GoogleHealthClassification, } from "./response-classifier"; +import type { + GoogleHealthDataPoint, + GoogleHealthDataType, + GoogleHealthRollupPoint, +} from "./mappers"; + +export * from "./mappers"; export const GOOGLE_HEALTH_API_BASE = "https://health.googleapis.com/v4"; const GOOGLE_OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; @@ -424,188 +438,6 @@ export const GOOGLE_HEALTH_PAGE_SIZE = 1000; /** Sleep/exercise read cap — matches the Google Health 25-cap for those types. */ export const GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE = 25; -/** - * One data type's on-the-wire encodings. `path` is the kebab-case segment - * spliced into the request URL; `filter` is the snake_case prefix used to build - * the incremental `filter=` predicate; `key` is the camelCase union key the - * response payload nests the value object under. `timeField` names the read - * method + time anchor. - */ -export interface GoogleHealthDataType { - /** kebab-case segment for the request path. */ - path: string; - /** snake_case prefix for the `filter` predicate. */ - filter: string; - /** camelCase union key in the response `DataPoint` payload. */ - key: string; - /** - * Which read method + time anchor the type uses: - * - `sample` → spot reading via list; filter - * `{filter}.sample_time.physical_time`, read `{key}.sampleTime.physicalTime`. - * - `date` → daily summary via list; filter `{filter}.date` - * (`YYYY-MM-DD`), read `{key}.date` (a `{year,month,day}` object). - * - `sessionEnd` → sleep sessions via list; sleep filters ONLY on - * `{filter}.interval.end_time` (any other field 400s); anchor - * `{key}.interval.endTime`. - * - `civilStart` → exercise sessions via list; session types filter ONLY on - * `{filter}.interval.civil_start_time` with an offset-less civil bound; - * times read from `{key}.interval.startTime/endTime` (RFC-3339). - * - `rollup` → cumulative daily totals via `POST :dailyRollUp` - * (`windowSizeDays: 1`); never listed. Day key `civilStartTime.date`. - */ - timeField: "sample" | "date" | "sessionEnd" | "civilStart" | "rollup"; -} - -/** - * The launch data types. Each entry pins the kebab-path + snake-filter + - * camelCase-payload triple so the three encodings can never drift. Identifiers - * reconciled against the official v4 reference (data-types index + per-type - * schema pages). - */ -export const GOOGLE_HEALTH_DATA_TYPES = { - weight: { - path: "weight", - filter: "weight", - key: "weight", - timeField: "sample", - }, - bodyFat: { - path: "body-fat", - filter: "body_fat", - key: "bodyFat", - timeField: "sample", - }, - // Daily-grain SpO2 lives on `daily-oxygen-saturation` (`averagePercentage`); - // the bare `oxygen-saturation` type is per-SAMPLE and does not accept a - // `.date` filter (400). - oxygenSaturation: { - path: "daily-oxygen-saturation", - filter: "daily_oxygen_saturation", - key: "dailyOxygenSaturation", - timeField: "date", - }, - // Nightly HRV summary lives on `daily-heart-rate-variability`; the bare - // `heart-rate-variability` type is per-sample (RMSSD + SDNN fields) and does - // not accept a `.date` filter. - heartRateVariability: { - path: "daily-heart-rate-variability", - filter: "daily_heart_rate_variability", - key: "dailyHeartRateVariability", - timeField: "date", - }, - restingHeartRate: { - path: "daily-resting-heart-rate", - filter: "daily_resting_heart_rate", - key: "dailyRestingHeartRate", - timeField: "date", - }, - // `respiratory-rate` does not exist in the catalogue; the daily summary is - // `daily-respiratory-rate`, value leaf `dailyRespiratoryRate.breathsPerMinute` - // (the once-assumed `dailyRespiratoryRateBpm` leaf never existed). - respiratoryRate: { - path: "daily-respiratory-rate", - filter: "daily_respiratory_rate", - key: "dailyRespiratoryRate", - timeField: "date", - }, - heartRate: { - path: "heart-rate", - filter: "heart_rate", - key: "heartRate", - timeField: "sample", - }, - height: { - path: "height", - filter: "height", - key: "height", - timeField: "sample", - }, - bloodGlucose: { - path: "blood-glucose", - filter: "blood_glucose", - key: "bloodGlucose", - timeField: "sample", - }, - coreBodyTemperature: { - path: "core-body-temperature", - filter: "core_body_temperature", - key: "coreBodyTemperature", - timeField: "sample", - }, - // Nightly sleep skin temperature. The documented `nightlyTemperatureCelsius` - // is an ABSOLUTE reading ("the mean of skin temperature samples taken from - // the user's sleep"), alongside a separate `baselineTemperatureCelsius` — so - // it maps cleanly onto the absolute WRIST_TEMPERATURE slot. (An earlier note - // claimed Google only ships a signed deviation; the schema refutes that.) - sleepTemperature: { - path: "daily-sleep-temperature-derivations", - filter: "daily_sleep_temperature_derivations", - key: "dailySleepTemperatureDerivations", - timeField: "date", - }, - // ── Activity bundle — daily cumulative totals ────────────────── - // Scope: `googlehealth.activity_and_fitness.readonly`. Read through - // `POST :dailyRollUp` with `windowSizeDays: 1`: the `list` surface returns - // minute-grain observation buckets, NOT daily totals (and floors has no list - // method at all). The externalId carries the `stats:` prefix so a re-fetched - // day overwrites in place (mirrors the Apple-Health - // `stats::` daily-total overwrite contract). - steps: { path: "steps", filter: "steps", key: "steps", timeField: "rollup" }, - distance: { - path: "distance", - filter: "distance", - key: "distance", - timeField: "rollup", - }, - // Active energy — canonical id `active-energy-burned`; this is the ACTIVE - // portion only, NOT `total-calories` (which folds in BMR). - activeEnergy: { - path: "active-energy-burned", - filter: "active_energy_burned", - key: "activeEnergyBurned", - timeField: "rollup", - }, - floors: { - path: "floors", - filter: "floors", - key: "floors", - timeField: "rollup", - }, - // The daily VO2-max reading lives on `daily-vo2-max` (`vo2Max`); the bare - // `vo2-max` type is per-sample and does not accept a `.date` filter. - vo2Max: { - path: "daily-vo2-max", - filter: "daily_vo2_max", - key: "dailyVo2Max", - timeField: "date", - }, - // ── Sleep bundle ─────────────────────────────────────────────── - // Scope: `googlehealth.sleep.readonly`. Sleep sessions filter ONLY on - // `sleep.interval.end_time` / `.civil_end_time` — a start-time filter 400s. - // Mapped to per-stage SLEEP_DURATION rows. - sleep: { - path: "sleep", - filter: "sleep", - key: "sleep", - timeField: "sessionEnd", - }, - // ── Exercise bundle ──────────────────────────────────────────── - // Scope: `googlehealth.activity_and_fitness.readonly`. Session types - // (excluding sleep/ECG) filter ONLY on `interval.civil_start_time` with an - // offset-less civil bound → a `Workout` row (NOT a Measurement). - exercise: { - path: "exercise", - filter: "exercise", - key: "exercise", - timeField: "civilStart", - }, -} as const satisfies Record; - -/** Google Health `DataPoint` — value object is type-keyed + carries a time anchor. */ -export interface GoogleHealthDataPoint { - [key: string]: unknown; -} - /** `dataPoints.list` envelope: `{ dataPoints, nextPageToken }`. */ interface GoogleHealthDataPointPage { dataPoints?: GoogleHealthDataPoint[]; @@ -998,17 +830,6 @@ export function chunkCivilRange( return out; } -/** - * One dailyRollUp aggregate window: `civilStartTime`/`civilEndTime` are - * CivilDateTime objects (`{date:{year,month,day}, time:{…}}`), the value is a - * union keyed by the camelCase type name carrying the `*Sum` rollup fields - * (`steps.countSum`, `distance.millimetersSum`, `activeEnergyBurned.kcalSum`, - * `floors.countSum` — int64 sums arrive as JSON strings). - */ -export interface GoogleHealthRollupPoint { - [key: string]: unknown; -} - /** `dataPoints:dailyRollUp` envelope. */ interface GoogleHealthRollupPage { rollupDataPoints?: GoogleHealthRollupPoint[]; @@ -1199,955 +1020,3 @@ export async function fetchDailyRollUp( query.onShape?.(shape); return points; } - -// ─── Field → Measurement mapping ─────────────────────────────── -// The single source of truth is `mapping.md` — keep both in sync when adding -// entries. - -/** Millimetres → centimetres (Google `height.heightMillimeters` → `User.heightCm`). */ -const MM_TO_CM = 0.1; - -function round2(n: number): number { - return parseFloat(n.toFixed(2)); -} - -/** - * Coerce a raw payload value to a finite number, or null. proto3 int64 fields - * arrive as JSON strings (`"12345"`); `Number()` handles both int and decimal - * strings, guarded by a finite check (empty / whitespace strings coerce to 0 - * via `Number("")` — reject them explicitly). - */ -function coerceNumber(v: unknown): number | null { - if (typeof v === "number") return Number.isFinite(v) ? v : null; - if (typeof v === "string" && v.trim() !== "") { - const n = Number(v); - return Number.isFinite(n) ? n : null; - } - return null; -} - -/** - * A single mapped reading destined for one `Measurement` row. The `source` - * (`GOOGLE_HEALTH`) and `externalId` (`:`) are stamped by the - * sync layer; the mapper emits only type/value/unit/measuredAt + the field-tag - * that disambiguates the externalId. - */ -export interface GoogleHealthMappedMeasurement { - type: string; - value: number; - unit: string; - measuredAt: Date; - /** Disambiguator appended to the per-point anchor to form the externalId. */ - fieldTag: string; - /** Per-stage sleep rows carry the SleepStage; everything else omits it. */ - sleepStage?: GoogleHealthSleepStage; - /** - * `true` when the externalId carries the `stats:` daily-total prefix (the - * cumulative activity metrics). The sync layer stamps the externalId; this - * flag lets it pick the right shape (`stats::` vs the - * `:` spot shape) without re-deriving the grain. - */ - cumulativeDaily?: boolean; -} - -/** HealthLog `SleepStage` values a Google sleep stage maps onto. */ -export type GoogleHealthSleepStage = - "IN_BED" | "AWAKE" | "ASLEEP" | "REM" | "CORE" | "DEEP"; - -/** - * Pull the first finite STRICTLY-POSITIVE number out of a list of candidate - * value paths on a `DataPoint`, coercing int64 JSON strings. The launch metrics - * (weight, body-fat, SpO2, HRV, RHR, respiratory rate, HR, height, VO2 max) are - * all strictly positive — a zero is a garbage/empty reading and is dropped. - */ -function firstNumber( - point: GoogleHealthDataPoint, - paths: string[], -): number | null { - for (const path of paths) { - const n = coerceNumber(readPath(point, path)); - if (n !== null && n > 0) return n; - } - return null; -} - -/** Resolve a dotted path against a nested object; undefined on any miss. */ -function readPath(obj: unknown, path: string): unknown { - let cur: unknown = obj; - for (const seg of path.split(".")) { - if (cur === null || typeof cur !== "object") return undefined; - cur = (cur as Record)[seg]; - } - return cur; -} - -/** Parse a `{year,month,day}` civil-date object into a UTC-midday Date, or null. */ -function parseCivilDateObject(val: unknown): Date | null { - if (!val || typeof val !== "object") return null; - const o = val as Record; - if ( - typeof o.year === "number" && - typeof o.month === "number" && - typeof o.day === "number" - ) { - // Google civil dates are 1-based months; anchor at UTC midday so a timezone - // shift can't roll the civil day across a boundary. - return new Date(Date.UTC(o.year, o.month - 1, o.day, 12)); - } - return null; -} - -/** - * Parse a Google civil START anchor — the calendar day a cumulative daily total - * belongs to — into a UTC-midday Date, or null. Accepts a `{year,month,day}` - * object OR a civil string (`YYYY-MM-DD`, optionally carrying a time suffix like - * `2026-06-02T00:00:00`); only the Y-M-D is kept, anchored at UTC midday so a - * timezone shift can't roll the civil day. Mirrors the Fitbit `parseCivilDate` - * UTC-midday convention. - */ -function parseCivilStart(val: unknown): Date | null { - if (typeof val === "string") { - const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(val.trim()); - if (!m) return null; - return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)); - } - return parseCivilDateObject(val); -} - -/** - * Matches an offset-less local ISO wall-clock string (`2026-06-02T03:02:30` / - * `...T03:02:30.000`). No trailing `Z`, no `±hh:mm` — those denote an absolute - * instant and are honoured verbatim. - */ -const OFFSET_LESS_LOCAL_ISO = - /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?$/; - -/** - * Parse a Google timestamp into a UTC instant. Sleep segments + exercise - * sessions can emit LOCAL wall-clock strings WITHOUT an offset — the night / - * session belongs to the user's local clock, so an offset-less string is - * resolved against the USER'S timezone, not the process zone (a bare - * `new Date(iso)` parses an offset-less string in the host zone, which shifts a - * non-UTC user by their offset and can flip a near-midnight wake-day). When `tz` - * is omitted the host-local fallback preserves the prior behaviour. Strings that - * DO carry an offset/`Z` are absolute and parsed as-is. Mirrors the Fitbit - * `parseLocalInstant`. Returns null on a miss. - */ -function parseLocalInstant(iso: string, tz?: string): Date | null { - const m = OFFSET_LESS_LOCAL_ISO.exec(iso.trim()); - if (m) { - return zonedWallClockToUtc( - { - year: Number(m[1]), - month: Number(m[2]), - day: Number(m[3]), - hour: Number(m[4]), - minute: Number(m[5]), - second: m[6] ? Number(m[6]) : 0, - }, - tz, - ); - } - const d = new Date(iso); - return Number.isNaN(d.getTime()) ? null : d; -} - -/** - * Resolve a `DataPoint`'s measurement timestamp. The read paths are camelCase — - * the response payload nests the value union under the camelCase type name with - * camelCase time objects (the snake_case forms exist only inside the request - * `filter` parameter). - * - `sample` → `{key}.sampleTime.physicalTime` (a spot instant; offset-less - * strings resolve against `tz`). - * - `date` → `{key}.date` (a `{year,month,day}` object, or a civil string) — - * anchored at UTC-midday so a tz shift can't roll the civil day. - * Falls back to `fallback` only when nothing parses, so a row is never dropped - * for a missing anchor. (Sleep / exercise sessions and the rollup types anchor - * through their own helpers, never here.) - */ -function resolveMeasuredAt( - point: GoogleHealthDataPoint, - dataType: GoogleHealthDataType, - fallback: Date, - tz?: string, -): Date { - if (dataType.timeField === "sample") { - const t = readPath(point, `${dataType.key}.sampleTime.physicalTime`); - if (typeof t === "string") { - const d = parseLocalInstant(t, tz); - if (d) return d; - } - } else if (dataType.timeField === "date") { - const dateVal = readPath(point, `${dataType.key}.date`); - const civil = parseCivilStart(dateVal); - if (civil) return civil; - } - return fallback; -} - -/** - * Stable anchor for a `DataPoint`'s externalId. A spot reading anchors on its - * sample time; a daily summary on its civil date. Combined with a type-specific - * field-tag this makes the upsert key idempotent across re-fetches. - */ -function externalAnchor( - point: GoogleHealthDataPoint, - dataType: GoogleHealthDataType, - tz?: string, -): string { - const at = resolveMeasuredAt(point, dataType, new Date(0), tz); - // Daily summaries share the civil-day externalId grain so a re-fetched day - // overwrites in place. (Sleep/exercise sessions and the rollup daily totals - // mint their own anchors and never reach this helper.) - if (dataType.timeField === "date") { - return at.toISOString().slice(0, 10); - } - return at.toISOString(); -} - -/** - * Map one data point of a simple single-value metric into a Measurement - * reading. `valuePaths` lists the candidate value shapes; the first - * finite-positive hit wins. Returns an empty array when no value parses. - */ -function mapSimple( - point: GoogleHealthDataPoint, - dataType: GoogleHealthDataType, - spec: { - type: string; - unit: string; - fieldTag: string; - valuePaths: string[]; - factor?: number; - }, -): GoogleHealthMappedMeasurement[] { - let value = firstNumber(point, spec.valuePaths); - if (value === null) return []; - if (spec.factor) value = value * spec.factor; - return [ - { - type: spec.type, - value: round2(value), - unit: spec.unit, - measuredAt: resolveMeasuredAt(point, dataType, new Date()), - fieldTag: `${externalAnchor(point, dataType)}:${spec.fieldTag}`, - }, - ]; -} - -export function mapWeight( - point: GoogleHealthDataPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.weight; - // Documented field is `weightGrams` (grams) → kg. - return mapSimple(point, dt, { - type: "WEIGHT", - unit: "kg", - fieldTag: "weight", - valuePaths: [`${dt.key}.weightGrams`], - factor: 0.001, - }); -} - -export function mapBodyFat( - point: GoogleHealthDataPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.bodyFat; - return mapSimple(point, dt, { - type: "BODY_FAT", - unit: "%", - fieldTag: "body_fat", - valuePaths: [`${dt.key}.percentage`], - }); -} - -export function mapOxygenSaturation( - point: GoogleHealthDataPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.oxygenSaturation; - return mapSimple(point, dt, { - type: "OXYGEN_SATURATION", - unit: "%", - fieldTag: "spo2", - valuePaths: [`${dt.key}.averagePercentage`], - }); -} - -export function mapHeartRateVariability( - point: GoogleHealthDataPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.heartRateVariability; - // The daily field is an unlabelled "average HRV ms". Per the design decision - // it lands in the SDNN-lineage `HEART_RATE_VARIABILITY` slot - // (Apple-comparable), NOT WHOOP's `HRV_RMSSD` (reserved for the WHOOP-native - // estimator). The per-sample type carries explicit RMSSD + SDNN fields — - // re-confirm the estimator against live data and revisit if warranted. - return mapSimple(point, dt, { - type: "HEART_RATE_VARIABILITY", - unit: "ms", - fieldTag: "hrv", - valuePaths: [`${dt.key}.averageHeartRateVariabilityMilliseconds`], - }); -} - -export function mapRestingHeartRate( - point: GoogleHealthDataPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.restingHeartRate; - // `beatsPerMinute` is an int64 JSON string — coerced by the extractor. - return mapSimple(point, dt, { - type: "RESTING_HEART_RATE", - unit: "bpm", - fieldTag: "rhr", - valuePaths: [`${dt.key}.beatsPerMinute`], - }); -} - -export function mapRespiratoryRate( - point: GoogleHealthDataPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.respiratoryRate; - // Documented schema: `{ date, breathsPerMinute }` — `breathsPerMinute` is a - // plain number ("The average number of breaths taken per minute"). The - // earlier `dailyRespiratoryRateBpm` leaf does not exist and never parsed. - return mapSimple(point, dt, { - type: "RESPIRATORY_RATE", - unit: "breaths/min", - fieldTag: "resp_rate", - valuePaths: [`${dt.key}.breathsPerMinute`], - }); -} - -export function mapBloodGlucose( - point: GoogleHealthDataPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.bloodGlucose; - // Documented field `bloodGlucoseMilligramsPerDeciliter` (number) — already in - // HealthLog's canonical mg/dL storage unit, no conversion. - return mapSimple(point, dt, { - type: "BLOOD_GLUCOSE", - unit: "mg/dL", - fieldTag: "glucose", - valuePaths: [`${dt.key}.bloodGlucoseMilligramsPerDeciliter`], - }); -} - -export function mapCoreBodyTemperature( - point: GoogleHealthDataPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.coreBodyTemperature; - // Documented field `temperatureCelsius` (number) → the core BODY_TEMPERATURE - // slot (distinct from SKIN_TEMPERATURE / WRIST_TEMPERATURE surface readings). - return mapSimple(point, dt, { - type: "BODY_TEMPERATURE", - unit: "celsius", - fieldTag: "core_temp", - valuePaths: [`${dt.key}.temperatureCelsius`], - }); -} - -export function mapWristTemperature( - point: GoogleHealthDataPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.sleepTemperature; - // `nightlyTemperatureCelsius` is the ABSOLUTE nightly skin temperature ("the - // mean of skin temperature samples taken from the user's sleep") → the - // WRIST_TEMPERATURE slot, mirroring the Apple sleeping-wrist-temperature - // absolute-reading convention. The sibling `baselineTemperatureCelsius` / - // `relativeNightlyStddev30dCelsius` derivations are not stored — the user's - // own series carries the baseline. - return mapSimple(point, dt, { - type: "WRIST_TEMPERATURE", - unit: "celsius", - fieldTag: "wrist_temp", - valuePaths: [`${dt.key}.nightlyTemperatureCelsius`], - }); -} - -export function mapHeartRate( - point: GoogleHealthDataPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.heartRate; - // `beatsPerMinute` is an int64 JSON string. - return mapSimple(point, dt, { - type: "PULSE", - unit: "bpm", - fieldTag: "hr", - valuePaths: [`${dt.key}.beatsPerMinute`], - }); -} - -/** One extracted `height` sample: centimetres + the sample instant (if any). */ -export interface GoogleHealthHeightSample { - cm: number; - sampledAt: Date | null; -} - -/** - * Extract the profile height from a Google `height` data point, or null when - * nothing parses. Height is a one-time `User.heightCm` profile seed (written - * only when the user has no height yet) — NOT a Measurement. The documented - * field is `heightMillimeters` (int64 JSON string, millimetres) → cm ÷ 10. - * (The earlier `heightMeters` leaf does not exist and never parsed.) - * `sampledAt` lets the caller pick the LATEST sample explicitly — list - * responses are ordered DESCENDING, so "last row wins" would pick the OLDEST. - */ -export function mapHeight( - point: GoogleHealthDataPoint, -): GoogleHealthHeightSample | null { - const dt = GOOGLE_HEALTH_DATA_TYPES.height; - const mm = firstNumber(point, [`${dt.key}.heightMillimeters`]); - if (mm === null) return null; - const t = readPath(point, `${dt.key}.sampleTime.physicalTime`); - const sampledAt = typeof t === "string" ? parseLocalInstant(t) : null; - return { cm: round2(mm * MM_TO_CM), sampledAt }; -} - -// ─── Activity mappers: daily roll-up totals ──────────────────── - -/** - * Pull the first finite NON-negative number out of a list of candidate value - * paths, coercing int64 JSON strings. Unlike `firstNumber` (strictly positive), - * this admits a legitimate zero — a day of rest still records 0 steps / - * 0 floors / 0 active kcal, and dropping the zero would leave a hole the chart - * misreads as missing data. - */ -function firstNonNegativeNumber( - point: GoogleHealthDataPoint, - paths: string[], -): number | null { - for (const path of paths) { - const n = coerceNumber(readPath(point, path)); - if (n !== null && n >= 0) return n; - } - return null; -} - -/** - * Map one `dailyRollUp` aggregate window (`windowSizeDays: 1`) into a single - * daily-total Measurement reading. The day key comes from the window's - * `civilStartTime.date` (a `{year,month,day}` object), anchored at UTC-midday - * per the shared civil-day convention; a window with no parseable civil day is - * dropped (it cannot be keyed). The externalId is the `stats:`-prefixed - * daily-total shape so a re-fetched day overwrites in place — the same - * overwrite contract the Apple-Health `stats::` daily totals - * use. A zero is preserved (a rest day is real data, not a gap). - */ -function mapDailyRollup( - point: GoogleHealthRollupPoint, - spec: { - type: string; - unit: string; - fieldTag: string; - valuePaths: string[]; - factor?: number; - }, -): GoogleHealthMappedMeasurement[] { - const day = - parseCivilDateObject(readPath(point, "civilStartTime.date")) ?? - parseCivilStart(readPath(point, "civilStartTime")); - if (!day) return []; - let value = firstNonNegativeNumber(point, spec.valuePaths); - if (value === null) return []; - if (spec.factor) value = value * spec.factor; - const dayKey = day.toISOString().slice(0, 10); - return [ - { - type: spec.type, - value: round2(value), - unit: spec.unit, - measuredAt: day, - // `stats::` — the sync layer reads `cumulativeDaily` - // to assemble the externalId, matching the Apple-Health daily-total shape. - fieldTag: `${spec.fieldTag}:${dayKey}`, - cumulativeDaily: true, - }, - ]; -} - -export function mapSteps( - point: GoogleHealthRollupPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.steps; - // `countSum` is an int64 JSON string. - return mapDailyRollup(point, { - type: "ACTIVITY_STEPS", - unit: "steps", - fieldTag: "steps", - valuePaths: [`${dt.key}.countSum`], - }); -} - -export function mapDistance( - point: GoogleHealthRollupPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.distance; - // `millimetersSum` is an int64 JSON string → metres. - return mapDailyRollup(point, { - type: "WALKING_RUNNING_DISTANCE", - unit: "m", - fieldTag: "distance", - valuePaths: [`${dt.key}.millimetersSum`], - factor: 0.001, - }); -} - -export function mapActiveEnergy( - point: GoogleHealthRollupPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.activeEnergy; - // ACTIVE energy only — NOT total-calories (which folds in BMR). - return mapDailyRollup(point, { - type: "ACTIVE_ENERGY_BURNED", - unit: "kcal", - fieldTag: "active_energy", - valuePaths: [`${dt.key}.kcalSum`], - }); -} - -export function mapFloors( - point: GoogleHealthRollupPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.floors; - // `countSum` is an int64 JSON string. - return mapDailyRollup(point, { - type: "FLIGHTS_CLIMBED", - unit: "flights", - fieldTag: "floors", - valuePaths: [`${dt.key}.countSum`], - }); -} - -/** - * Map one `daily-vo2-max` summary into a VO2_MAX reading. A daily latest-wins - * metric (one civil-date reading, strictly positive — not a running sum), read - * via list with a `.date` filter; it keeps the `stats:`-style per-day overwrite - * key so a re-rolled day replaces in place. - */ -export function mapVo2Max( - point: GoogleHealthDataPoint, -): GoogleHealthMappedMeasurement[] { - const dt = GOOGLE_HEALTH_DATA_TYPES.vo2Max; - const value = firstNumber(point, [`${dt.key}.vo2Max`]); - if (value === null) return []; - const measuredAt = resolveMeasuredAt(point, dt, new Date()); - return [ - { - type: "VO2_MAX", - value: round2(value), - unit: "mL/(kg·min)", - measuredAt, - fieldTag: `vo2_max:${externalAnchor(point, dt)}`, - cumulativeDaily: true, - }, - ]; -} - -// ── Sleep ────────────────────────────────────────────────────── -// -// A Google Health sleep session carries a list of per-stage segments, each with -// a stage label + a start + end. HealthLog stores one SLEEP_DURATION row per -// stage segment with `measuredAt = stage END` (so the night-total + hypnogram -// readers consume the same enum WHOOP / Apple write). The stage labels are -// harmonised onto the shared `SleepStage` enum. - -/** Google Health sleep stage label → HealthLog `SleepStage`. */ -const GOOGLE_HEALTH_SLEEP_STAGE_MAP: Record = { - // Canonical Google Health stage names (snake / lower variants accepted). - in_bed: "IN_BED", - inbed: "IN_BED", - awake: "AWAKE", - wake: "AWAKE", - light: "CORE", // "light" ↔ Apple "core" (same shallow-NREM band) - core: "CORE", - rem: "REM", - deep: "DEEP", - // The classic (non-stages) sleep log uses asleep/restless/wake. - asleep: "ASLEEP", - restless: "AWAKE", -} as const; - -/** - * Normalise a raw Google sleep-stage label to a `SleepStage`, or null for an - * unknown label (skipped rather than mis-bucketed). - */ -export function mapGoogleHealthSleepStage( - raw: unknown, -): GoogleHealthSleepStage | null { - if (typeof raw !== "string") return null; - const key = raw - .trim() - .toLowerCase() - .replace(/[\s-]+/g, "_"); - return ( - GOOGLE_HEALTH_SLEEP_STAGE_MAP[key] ?? - GOOGLE_HEALTH_SLEEP_STAGE_MAP[key.replace(/_/g, "")] ?? - null - ); -} - -/** One sleep-stage segment pulled defensively off a Google sleep session. */ -interface GoogleHealthSleepSegment { - stage: string; - startTime?: string; - endTime?: string; -} - -/** Minutes between two ISO instants, or null if either is unparseable. */ -function minutesBetween(startIso?: string, endIso?: string): number | null { - if (!startIso || !endIso) return null; - const s = new Date(startIso).getTime(); - const e = new Date(endIso).getTime(); - if (Number.isNaN(s) || Number.isNaN(e) || e <= s) return null; - return (e - s) / 60_000; -} - -/** - * Read the per-stage segments off a Google sleep `DataPoint`. Documented shape: - * `sleep.stages` is an array of - * `{ startTime, startUtcOffset, endTime, endUtcOffset, type }` where `type` is - * the `SLEEP_STAGE_TYPE` enum (`AWAKE | LIGHT | DEEP | REM | ASLEEP | RESTLESS - * | SLEEP_STAGE_TYPE_UNSPECIFIED`) and the times are RFC-3339 Timestamps. - */ -function readSleepSegments( - point: GoogleHealthDataPoint, -): GoogleHealthSleepSegment[] { - const stages = readPath(point, "sleep.stages"); - if (!Array.isArray(stages)) return []; - const out: GoogleHealthSleepSegment[] = []; - for (const raw of stages) { - if (!raw || typeof raw !== "object") continue; - const o = raw as Record; - const stage = typeof o.type === "string" ? o.type : ""; - const startTime = typeof o.startTime === "string" ? o.startTime : undefined; - const endTime = typeof o.endTime === "string" ? o.endTime : undefined; - if (stage) out.push({ stage, startTime, endTime }); - } - return out; -} - -/** - * The stable session anchor for a sleep `DataPoint`'s externalId. - * - * Prefers the Google resource id (`name`) — it is INVARIANT across Google's - * after-the-fact re-scoring of a night, so the per-segment rows keep the SAME - * externalId on a re-fetch and overwrite in place instead of minting parallel - * duplicates. This mirrors `mapWorkout`, which anchors on `name` for exactly - * this reason. - * - * The session interval end/start is only a FALLBACK (for a payload without a - * resource name): it SHIFTS whenever Google refines the wake/onset instant on a - * re-score, which is precisely what used to duplicate a night's rows. Paths are - * camelCase (`sleep.interval.endTime`) — the response payload never uses - * snake_case. - */ -function sleepSessionAnchor(point: GoogleHealthDataPoint, tz?: string): string { - const name = readPath(point, "name"); - if (typeof name === "string" && name !== "") return name; - const end = readPath(point, "sleep.interval.endTime"); - if (typeof end === "string") { - const d = parseLocalInstant(end, tz); - if (d) return d.toISOString(); - } - const start = readPath(point, "sleep.interval.startTime"); - if (typeof start === "string") { - const d = parseLocalInstant(start, tz); - if (d) return d.toISOString(); - } - return new Date(0).toISOString(); -} - -/** - * One mapped Google sleep session: the stable anchor, the session's covered - * time window (earliest segment start → latest segment end, both UTC), and the - * per-segment rows. The window drives the sync's replace-by-window cleanup of - * stale rows a re-score orphaned; `rows` is what gets upserted. - */ -export interface GoogleHealthSleepSession { - /** Stable session anchor — also the `:sleep:` externalId prefix. */ - anchor: string; - /** Earliest segment start across the session (UTC), or null if none map. */ - windowStart: Date | null; - /** Latest segment end across the session (UTC), or null if none map. */ - windowEnd: Date | null; - rows: GoogleHealthMappedMeasurement[]; -} - -/** - * Map one Google sleep session into per-SEGMENT `SLEEP_DURATION` rows plus its - * covered window. The Google sleep payload carries a real per-stage segment - * series (each with its own start/end), so one row is emitted PER SEGMENT — - * `measuredAt = that segment's END`. The timeline is MEASURED (real onsets), so - * these rows are NOT flagged reconstructed — unlike WHOOP, which has no onsets. - * - * Each segment's fieldTag keys off the STABLE session anchor plus the segment's - * own START instant — `:sleep:` — NOT a positional index - * and NOT the stage label. This is the fix for the re-score duplication: a - * positional index renumbered (and a stage-scoped tag changed) whenever Google - * re-scored a night, minting fresh externalIds so the upsert created parallel - * duplicate rows the night-total then double-counted. A segment's start instant - * is stable per block, and dropping the stage from the key means a mere - * re-classification (LIGHT→DEEP on the same block) UPDATES the row in place - * rather than orphaning it. Unknown stage labels are skipped; a session with no - * parseable segment yields an empty `rows` (and a null window). - * - * Segment timestamps can arrive OFFSET-LESS (local wall clock); `tz` (the user's - * stored zone) anchors them to the correct UTC instant rather than the process - * zone — without it a non-UTC user's near-midnight segment END would shift by - * their offset. Timestamps carrying an explicit offset/`Z` are honoured as-is. - */ -export function mapSleepSessionDetailed( - point: GoogleHealthDataPoint, - tz?: string, -): GoogleHealthSleepSession { - const anchor = sleepSessionAnchor(point, tz); - const segments = readSleepSegments(point); - const rows: GoogleHealthMappedMeasurement[] = []; - let windowStart: Date | null = null; - let windowEnd: Date | null = null; - - for (const seg of segments) { - const stage = mapGoogleHealthSleepStage(seg.stage); - if (!stage) continue; - const mins = minutesBetween(seg.startTime, seg.endTime); - if (mins === null || !(mins > 0)) continue; - const start = parseLocalInstant(seg.startTime as string, tz); - const end = parseLocalInstant(seg.endTime as string, tz); - if (!start || !end) continue; - if (!windowStart || start < windowStart) windowStart = start; - if (!windowEnd || end > windowEnd) windowEnd = end; - rows.push({ - type: "SLEEP_DURATION", - value: round2(mins), - unit: "minutes", - measuredAt: end, - fieldTag: `${anchor}:sleep:${start.toISOString()}`, - sleepStage: stage, - }); - } - - return { anchor, windowStart, windowEnd, rows }; -} - -/** - * Flat convenience wrapper — the per-segment rows of one session, without the - * window metadata. Retained for callers that only need the rows. - */ -export function mapSleepSession( - point: GoogleHealthDataPoint, - tz?: string, -): GoogleHealthMappedMeasurement[] { - return mapSleepSessionDetailed(point, tz).rows; -} - -// ── Workouts (exercise sessions) ─────────────────────────────── - -/** - * Google Health `Exercise.exerciseType` → HealthLog `WorkoutSportType`. The - * enum arrives UPPERCASE (`RUNNING`, `STRENGTH_TRAINING`, …); the resolver - * lowercases + underscores before the lookup, so the keys here are the - * normalised forms. Unknown types fall through to a generic label; the column - * is free-text so an unmapped type still persists (just not under a canonical - * sport bucket). - */ -const GOOGLE_HEALTH_EXERCISE_TYPE_MAP: Record = { - walk: "walking", - walking: "walking", - treadmill_walk: "walking", - incline_walk: "walking", - power_walking: "walking", - nordic_walking: "walking", - stroller_walk: "walking", - walk_with_weights: "walking", - run: "running", - running: "running", - treadmill: "running", - treadmill_running: "running", - trail_run: "running", - incline_run: "running", - bike: "cycling", - biking: "cycling", - cycling: "cycling", - spinning: "cycling", - mountain_biking: "cycling", - mountain_bike: "cycling", - outdoor_bike: "cycling", - stationary_bike: "cycling", - electric_bike: "cycling", - assault_bike: "cycling", - hike: "hiking", - hiking: "hiking", - backpacking: "hiking", - rucking: "hiking", - swim: "swimming", - swimming: "swimming", - swimming_pool: "swimming", - swimming_open_water: "swimming", - rowing: "rowing", - rowing_machine: "rowing", - elliptical: "elliptical", - stairclimber: "stairClimber", - stair_climbing: "stairClimber", - step_training: "stairClimber", - yoga: "yoga", - pilates: "mindAndBody", - meditate: "mindAndBody", - tai_chi: "mindAndBody", - stretching: "mindAndBody", - weights: "strength", - strength: "strength", - strength_training: "strength", - functional_strength_training: "strength", - weightlifting: "strength", - powerlifting: "strength", - free_weights: "strength", - body_weight: "strength", - resistance_bands: "strength", - calisthenics: "strength", - core_training: "strength", - workout: "strength", - hiit: "hiit", - high_intensity_interval_training: "hiit", - interval_workout: "hiit", - interval_training: "hiit", - tabata_workout: "hiit", - dance: "dance", - dancing: "dance", - golf: "golf", - tennis: "tennis", - basketball: "basketball", - soccer: "soccer", - football: "soccer", - bootcamp: "crossTraining", - circuit_training: "crossTraining", - crossfit: "crossTraining", - cross_training: "crossTraining", - aerobic_workout: "mixedCardio", - cardio_workout: "mixedCardio", - cardio_sculpt: "mixedCardio", - sport: "mixedCardio", -} as const; - -/** Resolve a Google exercise activity type to a canonical sport label. */ -export function mapGoogleHealthSportType(raw: unknown): string { - if (typeof raw !== "string" || raw.trim() === "") return "other"; - const key = raw - .trim() - .toLowerCase() - .replace(/[\s-]+/g, "_"); - return ( - GOOGLE_HEALTH_EXERCISE_TYPE_MAP[key] ?? - GOOGLE_HEALTH_EXERCISE_TYPE_MAP[key.replace(/_/g, "")] ?? - "other" - ); -} - -/** One mapped Google exercise session destined for a `Workout` row. */ -export interface GoogleHealthMappedWorkout { - externalId: string; - sportType: string; - startedAt: Date; - endedAt: Date; - durationSec: number; - totalEnergyKcal: number | null; - totalDistanceM: number | null; - avgHeartRate: number | null; - maxHeartRate: number | null; - minHeartRate: number | null; -} - -/** - * Read a finite number off a list of candidate paths (any sign), coercing int64 - * JSON strings, or null. - */ -function readNumber( - point: GoogleHealthDataPoint, - paths: string[], -): number | null { - for (const path of paths) { - const n = coerceNumber(readPath(point, path)); - if (n !== null) return n; - } - return null; -} - -/** - * Read an instant off a list of candidate paths, or null. Offset-less strings - * resolve against `tz` (the user's stored zone) rather than the process zone; - * strings carrying an explicit offset/`Z` are honoured as-is. - */ -function readInstant( - point: GoogleHealthDataPoint, - paths: string[], - tz?: string, -): Date | null { - for (const path of paths) { - const v = readPath(point, path); - if (typeof v === "string") { - const d = parseLocalInstant(v, tz); - if (d) return d; - } - } - return null; -} - -/** - * Map one Google exercise session `DataPoint` into a `Workout` shape. Returns - * null when there is no usable start/end (a session with no time span is not a - * workout). - * - * Documented payload: `exercise.interval.startTime/endTime` (RFC-3339), - * `exercise.exerciseType` (UPPERCASE enum), and - * `exercise.metricsSummary.{caloriesKcal, distanceMillimeters, - * averageHeartRateBeatsPerMinute (int64 string), …}`. There is no session-id - * field — the DataPoint's top-level `name` (a resource name) is the stable id; - * the start instant is the fallback. metricsSummary carries no max/min HR → - * null. - * - * Session start/end can arrive OFFSET-LESS (local wall clock); `tz` anchors - * them to the correct UTC instant rather than the process zone. - */ -export function mapWorkout( - point: GoogleHealthDataPoint, - tz?: string, -): GoogleHealthMappedWorkout | null { - const k = GOOGLE_HEALTH_DATA_TYPES.exercise.key; - const startedAt = readInstant(point, [`${k}.interval.startTime`], tz); - const endedAt = readInstant(point, [`${k}.interval.endTime`], tz); - if (!startedAt || !endedAt || endedAt <= startedAt) return null; - - const durationSec = Math.round( - (endedAt.getTime() - startedAt.getTime()) / 1000, - ); - - const name = readPath(point, "name"); - const externalId = - typeof name === "string" && name !== "" - ? name - : `exercise:${startedAt.toISOString()}`; - - const sportRaw = readPath(point, `${k}.exerciseType`); - - const energyKcal = readNumber(point, [`${k}.metricsSummary.caloriesKcal`]); - const distanceMm = readNumber(point, [ - `${k}.metricsSummary.distanceMillimeters`, - ]); - const avgHr = readNumber(point, [ - `${k}.metricsSummary.averageHeartRateBeatsPerMinute`, - ]); - - return { - externalId, - sportType: mapGoogleHealthSportType(sportRaw), - startedAt, - endedAt, - durationSec, - totalEnergyKcal: energyKcal !== null ? Math.round(energyKcal) : null, - totalDistanceM: - distanceMm !== null && distanceMm >= 0 ? round2(distanceMm / 1000) : null, - avgHeartRate: avgHr !== null && avgHr > 0 ? Math.round(avgHr) : null, - // metricsSummary carries no maximum/minimum heart rate. - maxHeartRate: null, - minHeartRate: null, - }; -} diff --git a/src/lib/google-health/mappers.ts b/src/lib/google-health/mappers.ts new file mode 100644 index 000000000..28ec45e71 --- /dev/null +++ b/src/lib/google-health/mappers.ts @@ -0,0 +1,1162 @@ +/** + * Google Health pure mapping layer — payload → Measurement / Workout shapes. + * + * Split out of `client.ts` as pure code motion: the per-type `map*` functions, + * the civil-date / local-instant parsing helpers, the sleep-stage + sport-type + * resolvers, and the mapped-row types. `client.ts` re-exports this module, so + * the sync layer and tests keep importing from `./client` unchanged. + * + * The shared wire-shape symbols (`GoogleHealthDataType`, + * `GOOGLE_HEALTH_DATA_TYPES`, `GoogleHealthDataPoint`, + * `GoogleHealthRollupPoint`) live HERE and `client.ts` imports them type-only: + * that direction keeps the module graph acyclic (`client` → `mappers`, never + * back), since `client.ts` also runtime-re-exports this module. No function in + * this file performs I/O — everything is synchronous and deterministic given a + * payload (+ optional user timezone). + */ +import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; + +/** + * One data type's on-the-wire encodings. `path` is the kebab-case segment + * spliced into the request URL; `filter` is the snake_case prefix used to build + * the incremental `filter=` predicate; `key` is the camelCase union key the + * response payload nests the value object under. `timeField` names the read + * method + time anchor. + */ +export interface GoogleHealthDataType { + /** kebab-case segment for the request path. */ + path: string; + /** snake_case prefix for the `filter` predicate. */ + filter: string; + /** camelCase union key in the response `DataPoint` payload. */ + key: string; + /** + * Which read method + time anchor the type uses: + * - `sample` → spot reading via list; filter + * `{filter}.sample_time.physical_time`, read `{key}.sampleTime.physicalTime`. + * - `date` → daily summary via list; filter `{filter}.date` + * (`YYYY-MM-DD`), read `{key}.date` (a `{year,month,day}` object). + * - `sessionEnd` → sleep sessions via list; sleep filters ONLY on + * `{filter}.interval.end_time` (any other field 400s); anchor + * `{key}.interval.endTime`. + * - `civilStart` → exercise sessions via list; session types filter ONLY on + * `{filter}.interval.civil_start_time` with an offset-less civil bound; + * times read from `{key}.interval.startTime/endTime` (RFC-3339). + * - `rollup` → cumulative daily totals via `POST :dailyRollUp` + * (`windowSizeDays: 1`); never listed. Day key `civilStartTime.date`. + */ + timeField: "sample" | "date" | "sessionEnd" | "civilStart" | "rollup"; +} + +/** + * The launch data types. Each entry pins the kebab-path + snake-filter + + * camelCase-payload triple so the three encodings can never drift. Identifiers + * reconciled against the official v4 reference (data-types index + per-type + * schema pages). + */ +export const GOOGLE_HEALTH_DATA_TYPES = { + weight: { + path: "weight", + filter: "weight", + key: "weight", + timeField: "sample", + }, + bodyFat: { + path: "body-fat", + filter: "body_fat", + key: "bodyFat", + timeField: "sample", + }, + // Daily-grain SpO2 lives on `daily-oxygen-saturation` (`averagePercentage`); + // the bare `oxygen-saturation` type is per-SAMPLE and does not accept a + // `.date` filter (400). + oxygenSaturation: { + path: "daily-oxygen-saturation", + filter: "daily_oxygen_saturation", + key: "dailyOxygenSaturation", + timeField: "date", + }, + // Nightly HRV summary lives on `daily-heart-rate-variability`; the bare + // `heart-rate-variability` type is per-sample (RMSSD + SDNN fields) and does + // not accept a `.date` filter. + heartRateVariability: { + path: "daily-heart-rate-variability", + filter: "daily_heart_rate_variability", + key: "dailyHeartRateVariability", + timeField: "date", + }, + restingHeartRate: { + path: "daily-resting-heart-rate", + filter: "daily_resting_heart_rate", + key: "dailyRestingHeartRate", + timeField: "date", + }, + // `respiratory-rate` does not exist in the catalogue; the daily summary is + // `daily-respiratory-rate`, value leaf `dailyRespiratoryRate.breathsPerMinute` + // (the once-assumed `dailyRespiratoryRateBpm` leaf never existed). + respiratoryRate: { + path: "daily-respiratory-rate", + filter: "daily_respiratory_rate", + key: "dailyRespiratoryRate", + timeField: "date", + }, + heartRate: { + path: "heart-rate", + filter: "heart_rate", + key: "heartRate", + timeField: "sample", + }, + height: { + path: "height", + filter: "height", + key: "height", + timeField: "sample", + }, + bloodGlucose: { + path: "blood-glucose", + filter: "blood_glucose", + key: "bloodGlucose", + timeField: "sample", + }, + coreBodyTemperature: { + path: "core-body-temperature", + filter: "core_body_temperature", + key: "coreBodyTemperature", + timeField: "sample", + }, + // Nightly sleep skin temperature. The documented `nightlyTemperatureCelsius` + // is an ABSOLUTE reading ("the mean of skin temperature samples taken from + // the user's sleep"), alongside a separate `baselineTemperatureCelsius` — so + // it maps cleanly onto the absolute WRIST_TEMPERATURE slot. (An earlier note + // claimed Google only ships a signed deviation; the schema refutes that.) + sleepTemperature: { + path: "daily-sleep-temperature-derivations", + filter: "daily_sleep_temperature_derivations", + key: "dailySleepTemperatureDerivations", + timeField: "date", + }, + // ── Activity bundle — daily cumulative totals ────────────────── + // Scope: `googlehealth.activity_and_fitness.readonly`. Read through + // `POST :dailyRollUp` with `windowSizeDays: 1`: the `list` surface returns + // minute-grain observation buckets, NOT daily totals (and floors has no list + // method at all). The externalId carries the `stats:` prefix so a re-fetched + // day overwrites in place (mirrors the Apple-Health + // `stats::` daily-total overwrite contract). + steps: { path: "steps", filter: "steps", key: "steps", timeField: "rollup" }, + distance: { + path: "distance", + filter: "distance", + key: "distance", + timeField: "rollup", + }, + // Active energy — canonical id `active-energy-burned`; this is the ACTIVE + // portion only, NOT `total-calories` (which folds in BMR). + activeEnergy: { + path: "active-energy-burned", + filter: "active_energy_burned", + key: "activeEnergyBurned", + timeField: "rollup", + }, + floors: { + path: "floors", + filter: "floors", + key: "floors", + timeField: "rollup", + }, + // The daily VO2-max reading lives on `daily-vo2-max` (`vo2Max`); the bare + // `vo2-max` type is per-sample and does not accept a `.date` filter. + vo2Max: { + path: "daily-vo2-max", + filter: "daily_vo2_max", + key: "dailyVo2Max", + timeField: "date", + }, + // ── Sleep bundle ─────────────────────────────────────────────── + // Scope: `googlehealth.sleep.readonly`. Sleep sessions filter ONLY on + // `sleep.interval.end_time` / `.civil_end_time` — a start-time filter 400s. + // Mapped to per-stage SLEEP_DURATION rows. + sleep: { + path: "sleep", + filter: "sleep", + key: "sleep", + timeField: "sessionEnd", + }, + // ── Exercise bundle ──────────────────────────────────────────── + // Scope: `googlehealth.activity_and_fitness.readonly`. Session types + // (excluding sleep/ECG) filter ONLY on `interval.civil_start_time` with an + // offset-less civil bound → a `Workout` row (NOT a Measurement). + exercise: { + path: "exercise", + filter: "exercise", + key: "exercise", + timeField: "civilStart", + }, +} as const satisfies Record; + +/** Google Health `DataPoint` — value object is type-keyed + carries a time anchor. */ +export interface GoogleHealthDataPoint { + [key: string]: unknown; +} + +/** + * One dailyRollUp aggregate window: `civilStartTime`/`civilEndTime` are + * CivilDateTime objects (`{date:{year,month,day}, time:{…}}`), the value is a + * union keyed by the camelCase type name carrying the `*Sum` rollup fields + * (`steps.countSum`, `distance.millimetersSum`, `activeEnergyBurned.kcalSum`, + * `floors.countSum` — int64 sums arrive as JSON strings). + */ +export interface GoogleHealthRollupPoint { + [key: string]: unknown; +} + +// ─── Field → Measurement mapping ─────────────────────────────── +// The single source of truth is `mapping.md` — keep both in sync when adding +// entries. + +/** Millimetres → centimetres (Google `height.heightMillimeters` → `User.heightCm`). */ +const MM_TO_CM = 0.1; + +function round2(n: number): number { + return parseFloat(n.toFixed(2)); +} + +/** + * Coerce a raw payload value to a finite number, or null. proto3 int64 fields + * arrive as JSON strings (`"12345"`); `Number()` handles both int and decimal + * strings, guarded by a finite check (empty / whitespace strings coerce to 0 + * via `Number("")` — reject them explicitly). + */ +function coerceNumber(v: unknown): number | null { + if (typeof v === "number") return Number.isFinite(v) ? v : null; + if (typeof v === "string" && v.trim() !== "") { + const n = Number(v); + return Number.isFinite(n) ? n : null; + } + return null; +} + +/** + * A single mapped reading destined for one `Measurement` row. The `source` + * (`GOOGLE_HEALTH`) and `externalId` (`:`) are stamped by the + * sync layer; the mapper emits only type/value/unit/measuredAt + the field-tag + * that disambiguates the externalId. + */ +export interface GoogleHealthMappedMeasurement { + type: string; + value: number; + unit: string; + measuredAt: Date; + /** Disambiguator appended to the per-point anchor to form the externalId. */ + fieldTag: string; + /** Per-stage sleep rows carry the SleepStage; everything else omits it. */ + sleepStage?: GoogleHealthSleepStage; + /** + * `true` when the externalId carries the `stats:` daily-total prefix (the + * cumulative activity metrics). The sync layer stamps the externalId; this + * flag lets it pick the right shape (`stats::` vs the + * `:` spot shape) without re-deriving the grain. + */ + cumulativeDaily?: boolean; +} + +/** HealthLog `SleepStage` values a Google sleep stage maps onto. */ +export type GoogleHealthSleepStage = + "IN_BED" | "AWAKE" | "ASLEEP" | "REM" | "CORE" | "DEEP"; + +/** + * Pull the first finite STRICTLY-POSITIVE number out of a list of candidate + * value paths on a `DataPoint`, coercing int64 JSON strings. The launch metrics + * (weight, body-fat, SpO2, HRV, RHR, respiratory rate, HR, height, VO2 max) are + * all strictly positive — a zero is a garbage/empty reading and is dropped. + */ +function firstNumber( + point: GoogleHealthDataPoint, + paths: string[], +): number | null { + for (const path of paths) { + const n = coerceNumber(readPath(point, path)); + if (n !== null && n > 0) return n; + } + return null; +} + +/** Resolve a dotted path against a nested object; undefined on any miss. */ +function readPath(obj: unknown, path: string): unknown { + let cur: unknown = obj; + for (const seg of path.split(".")) { + if (cur === null || typeof cur !== "object") return undefined; + cur = (cur as Record)[seg]; + } + return cur; +} + +/** Parse a `{year,month,day}` civil-date object into a UTC-midday Date, or null. */ +function parseCivilDateObject(val: unknown): Date | null { + if (!val || typeof val !== "object") return null; + const o = val as Record; + if ( + typeof o.year === "number" && + typeof o.month === "number" && + typeof o.day === "number" + ) { + // Google civil dates are 1-based months; anchor at UTC midday so a timezone + // shift can't roll the civil day across a boundary. + return new Date(Date.UTC(o.year, o.month - 1, o.day, 12)); + } + return null; +} + +/** + * Parse a Google civil START anchor — the calendar day a cumulative daily total + * belongs to — into a UTC-midday Date, or null. Accepts a `{year,month,day}` + * object OR a civil string (`YYYY-MM-DD`, optionally carrying a time suffix like + * `2026-06-02T00:00:00`); only the Y-M-D is kept, anchored at UTC midday so a + * timezone shift can't roll the civil day. Mirrors the Fitbit `parseCivilDate` + * UTC-midday convention. + */ +function parseCivilStart(val: unknown): Date | null { + if (typeof val === "string") { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(val.trim()); + if (!m) return null; + return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)); + } + return parseCivilDateObject(val); +} + +/** + * Matches an offset-less local ISO wall-clock string (`2026-06-02T03:02:30` / + * `...T03:02:30.000`). No trailing `Z`, no `±hh:mm` — those denote an absolute + * instant and are honoured verbatim. + */ +const OFFSET_LESS_LOCAL_ISO = + /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?$/; + +/** + * Parse a Google timestamp into a UTC instant. Sleep segments + exercise + * sessions can emit LOCAL wall-clock strings WITHOUT an offset — the night / + * session belongs to the user's local clock, so an offset-less string is + * resolved against the USER'S timezone, not the process zone (a bare + * `new Date(iso)` parses an offset-less string in the host zone, which shifts a + * non-UTC user by their offset and can flip a near-midnight wake-day). When `tz` + * is omitted the host-local fallback preserves the prior behaviour. Strings that + * DO carry an offset/`Z` are absolute and parsed as-is. Mirrors the Fitbit + * `parseLocalInstant`. Returns null on a miss. + */ +function parseLocalInstant(iso: string, tz?: string): Date | null { + const m = OFFSET_LESS_LOCAL_ISO.exec(iso.trim()); + if (m) { + return zonedWallClockToUtc( + { + year: Number(m[1]), + month: Number(m[2]), + day: Number(m[3]), + hour: Number(m[4]), + minute: Number(m[5]), + second: m[6] ? Number(m[6]) : 0, + }, + tz, + ); + } + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d; +} + +/** + * Resolve a `DataPoint`'s measurement timestamp. The read paths are camelCase — + * the response payload nests the value union under the camelCase type name with + * camelCase time objects (the snake_case forms exist only inside the request + * `filter` parameter). + * - `sample` → `{key}.sampleTime.physicalTime` (a spot instant; offset-less + * strings resolve against `tz`). + * - `date` → `{key}.date` (a `{year,month,day}` object, or a civil string) — + * anchored at UTC-midday so a tz shift can't roll the civil day. + * Falls back to `fallback` only when nothing parses, so a row is never dropped + * for a missing anchor. (Sleep / exercise sessions and the rollup types anchor + * through their own helpers, never here.) + */ +function resolveMeasuredAt( + point: GoogleHealthDataPoint, + dataType: GoogleHealthDataType, + fallback: Date, + tz?: string, +): Date { + if (dataType.timeField === "sample") { + const t = readPath(point, `${dataType.key}.sampleTime.physicalTime`); + if (typeof t === "string") { + const d = parseLocalInstant(t, tz); + if (d) return d; + } + } else if (dataType.timeField === "date") { + const dateVal = readPath(point, `${dataType.key}.date`); + const civil = parseCivilStart(dateVal); + if (civil) return civil; + } + return fallback; +} + +/** + * Stable anchor for a `DataPoint`'s externalId. A spot reading anchors on its + * sample time; a daily summary on its civil date. Combined with a type-specific + * field-tag this makes the upsert key idempotent across re-fetches. + */ +function externalAnchor( + point: GoogleHealthDataPoint, + dataType: GoogleHealthDataType, + tz?: string, +): string { + const at = resolveMeasuredAt(point, dataType, new Date(0), tz); + // Daily summaries share the civil-day externalId grain so a re-fetched day + // overwrites in place. (Sleep/exercise sessions and the rollup daily totals + // mint their own anchors and never reach this helper.) + if (dataType.timeField === "date") { + return at.toISOString().slice(0, 10); + } + return at.toISOString(); +} + +/** + * Map one data point of a simple single-value metric into a Measurement + * reading. `valuePaths` lists the candidate value shapes; the first + * finite-positive hit wins. Returns an empty array when no value parses. + */ +function mapSimple( + point: GoogleHealthDataPoint, + dataType: GoogleHealthDataType, + spec: { + type: string; + unit: string; + fieldTag: string; + valuePaths: string[]; + factor?: number; + }, +): GoogleHealthMappedMeasurement[] { + let value = firstNumber(point, spec.valuePaths); + if (value === null) return []; + if (spec.factor) value = value * spec.factor; + return [ + { + type: spec.type, + value: round2(value), + unit: spec.unit, + measuredAt: resolveMeasuredAt(point, dataType, new Date()), + fieldTag: `${externalAnchor(point, dataType)}:${spec.fieldTag}`, + }, + ]; +} + +export function mapWeight( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.weight; + // Documented field is `weightGrams` (grams) → kg. + return mapSimple(point, dt, { + type: "WEIGHT", + unit: "kg", + fieldTag: "weight", + valuePaths: [`${dt.key}.weightGrams`], + factor: 0.001, + }); +} + +export function mapBodyFat( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.bodyFat; + return mapSimple(point, dt, { + type: "BODY_FAT", + unit: "%", + fieldTag: "body_fat", + valuePaths: [`${dt.key}.percentage`], + }); +} + +export function mapOxygenSaturation( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.oxygenSaturation; + return mapSimple(point, dt, { + type: "OXYGEN_SATURATION", + unit: "%", + fieldTag: "spo2", + valuePaths: [`${dt.key}.averagePercentage`], + }); +} + +export function mapHeartRateVariability( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.heartRateVariability; + // The daily field is an unlabelled "average HRV ms". Per the design decision + // it lands in the SDNN-lineage `HEART_RATE_VARIABILITY` slot + // (Apple-comparable), NOT WHOOP's `HRV_RMSSD` (reserved for the WHOOP-native + // estimator). The per-sample type carries explicit RMSSD + SDNN fields — + // re-confirm the estimator against live data and revisit if warranted. + return mapSimple(point, dt, { + type: "HEART_RATE_VARIABILITY", + unit: "ms", + fieldTag: "hrv", + valuePaths: [`${dt.key}.averageHeartRateVariabilityMilliseconds`], + }); +} + +export function mapRestingHeartRate( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.restingHeartRate; + // `beatsPerMinute` is an int64 JSON string — coerced by the extractor. + return mapSimple(point, dt, { + type: "RESTING_HEART_RATE", + unit: "bpm", + fieldTag: "rhr", + valuePaths: [`${dt.key}.beatsPerMinute`], + }); +} + +export function mapRespiratoryRate( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.respiratoryRate; + // Documented schema: `{ date, breathsPerMinute }` — `breathsPerMinute` is a + // plain number ("The average number of breaths taken per minute"). The + // earlier `dailyRespiratoryRateBpm` leaf does not exist and never parsed. + return mapSimple(point, dt, { + type: "RESPIRATORY_RATE", + unit: "breaths/min", + fieldTag: "resp_rate", + valuePaths: [`${dt.key}.breathsPerMinute`], + }); +} + +export function mapBloodGlucose( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.bloodGlucose; + // Documented field `bloodGlucoseMilligramsPerDeciliter` (number) — already in + // HealthLog's canonical mg/dL storage unit, no conversion. + return mapSimple(point, dt, { + type: "BLOOD_GLUCOSE", + unit: "mg/dL", + fieldTag: "glucose", + valuePaths: [`${dt.key}.bloodGlucoseMilligramsPerDeciliter`], + }); +} + +export function mapCoreBodyTemperature( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.coreBodyTemperature; + // Documented field `temperatureCelsius` (number) → the core BODY_TEMPERATURE + // slot (distinct from SKIN_TEMPERATURE / WRIST_TEMPERATURE surface readings). + return mapSimple(point, dt, { + type: "BODY_TEMPERATURE", + unit: "celsius", + fieldTag: "core_temp", + valuePaths: [`${dt.key}.temperatureCelsius`], + }); +} + +export function mapWristTemperature( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.sleepTemperature; + // `nightlyTemperatureCelsius` is the ABSOLUTE nightly skin temperature ("the + // mean of skin temperature samples taken from the user's sleep") → the + // WRIST_TEMPERATURE slot, mirroring the Apple sleeping-wrist-temperature + // absolute-reading convention. The sibling `baselineTemperatureCelsius` / + // `relativeNightlyStddev30dCelsius` derivations are not stored — the user's + // own series carries the baseline. + return mapSimple(point, dt, { + type: "WRIST_TEMPERATURE", + unit: "celsius", + fieldTag: "wrist_temp", + valuePaths: [`${dt.key}.nightlyTemperatureCelsius`], + }); +} + +export function mapHeartRate( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.heartRate; + // `beatsPerMinute` is an int64 JSON string. + return mapSimple(point, dt, { + type: "PULSE", + unit: "bpm", + fieldTag: "hr", + valuePaths: [`${dt.key}.beatsPerMinute`], + }); +} + +/** One extracted `height` sample: centimetres + the sample instant (if any). */ +export interface GoogleHealthHeightSample { + cm: number; + sampledAt: Date | null; +} + +/** + * Extract the profile height from a Google `height` data point, or null when + * nothing parses. Height is a one-time `User.heightCm` profile seed (written + * only when the user has no height yet) — NOT a Measurement. The documented + * field is `heightMillimeters` (int64 JSON string, millimetres) → cm ÷ 10. + * (The earlier `heightMeters` leaf does not exist and never parsed.) + * `sampledAt` lets the caller pick the LATEST sample explicitly — list + * responses are ordered DESCENDING, so "last row wins" would pick the OLDEST. + */ +export function mapHeight( + point: GoogleHealthDataPoint, +): GoogleHealthHeightSample | null { + const dt = GOOGLE_HEALTH_DATA_TYPES.height; + const mm = firstNumber(point, [`${dt.key}.heightMillimeters`]); + if (mm === null) return null; + const t = readPath(point, `${dt.key}.sampleTime.physicalTime`); + const sampledAt = typeof t === "string" ? parseLocalInstant(t) : null; + return { cm: round2(mm * MM_TO_CM), sampledAt }; +} + +// ─── Activity mappers: daily roll-up totals ──────────────────── + +/** + * Pull the first finite NON-negative number out of a list of candidate value + * paths, coercing int64 JSON strings. Unlike `firstNumber` (strictly positive), + * this admits a legitimate zero — a day of rest still records 0 steps / + * 0 floors / 0 active kcal, and dropping the zero would leave a hole the chart + * misreads as missing data. + */ +function firstNonNegativeNumber( + point: GoogleHealthDataPoint, + paths: string[], +): number | null { + for (const path of paths) { + const n = coerceNumber(readPath(point, path)); + if (n !== null && n >= 0) return n; + } + return null; +} + +/** + * Map one `dailyRollUp` aggregate window (`windowSizeDays: 1`) into a single + * daily-total Measurement reading. The day key comes from the window's + * `civilStartTime.date` (a `{year,month,day}` object), anchored at UTC-midday + * per the shared civil-day convention; a window with no parseable civil day is + * dropped (it cannot be keyed). The externalId is the `stats:`-prefixed + * daily-total shape so a re-fetched day overwrites in place — the same + * overwrite contract the Apple-Health `stats::` daily totals + * use. A zero is preserved (a rest day is real data, not a gap). + */ +function mapDailyRollup( + point: GoogleHealthRollupPoint, + spec: { + type: string; + unit: string; + fieldTag: string; + valuePaths: string[]; + factor?: number; + }, +): GoogleHealthMappedMeasurement[] { + const day = + parseCivilDateObject(readPath(point, "civilStartTime.date")) ?? + parseCivilStart(readPath(point, "civilStartTime")); + if (!day) return []; + let value = firstNonNegativeNumber(point, spec.valuePaths); + if (value === null) return []; + if (spec.factor) value = value * spec.factor; + const dayKey = day.toISOString().slice(0, 10); + return [ + { + type: spec.type, + value: round2(value), + unit: spec.unit, + measuredAt: day, + // `stats::` — the sync layer reads `cumulativeDaily` + // to assemble the externalId, matching the Apple-Health daily-total shape. + fieldTag: `${spec.fieldTag}:${dayKey}`, + cumulativeDaily: true, + }, + ]; +} + +export function mapSteps( + point: GoogleHealthRollupPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.steps; + // `countSum` is an int64 JSON string. + return mapDailyRollup(point, { + type: "ACTIVITY_STEPS", + unit: "steps", + fieldTag: "steps", + valuePaths: [`${dt.key}.countSum`], + }); +} + +export function mapDistance( + point: GoogleHealthRollupPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.distance; + // `millimetersSum` is an int64 JSON string → metres. + return mapDailyRollup(point, { + type: "WALKING_RUNNING_DISTANCE", + unit: "m", + fieldTag: "distance", + valuePaths: [`${dt.key}.millimetersSum`], + factor: 0.001, + }); +} + +export function mapActiveEnergy( + point: GoogleHealthRollupPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.activeEnergy; + // ACTIVE energy only — NOT total-calories (which folds in BMR). + return mapDailyRollup(point, { + type: "ACTIVE_ENERGY_BURNED", + unit: "kcal", + fieldTag: "active_energy", + valuePaths: [`${dt.key}.kcalSum`], + }); +} + +export function mapFloors( + point: GoogleHealthRollupPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.floors; + // `countSum` is an int64 JSON string. + return mapDailyRollup(point, { + type: "FLIGHTS_CLIMBED", + unit: "flights", + fieldTag: "floors", + valuePaths: [`${dt.key}.countSum`], + }); +} + +/** + * Map one `daily-vo2-max` summary into a VO2_MAX reading. A daily latest-wins + * metric (one civil-date reading, strictly positive — not a running sum), read + * via list with a `.date` filter; it keeps the `stats:`-style per-day overwrite + * key so a re-rolled day replaces in place. + */ +export function mapVo2Max( + point: GoogleHealthDataPoint, +): GoogleHealthMappedMeasurement[] { + const dt = GOOGLE_HEALTH_DATA_TYPES.vo2Max; + const value = firstNumber(point, [`${dt.key}.vo2Max`]); + if (value === null) return []; + const measuredAt = resolveMeasuredAt(point, dt, new Date()); + return [ + { + type: "VO2_MAX", + value: round2(value), + unit: "mL/(kg·min)", + measuredAt, + fieldTag: `vo2_max:${externalAnchor(point, dt)}`, + cumulativeDaily: true, + }, + ]; +} + +// ── Sleep ────────────────────────────────────────────────────── +// +// A Google Health sleep session carries a list of per-stage segments, each with +// a stage label + a start + end. HealthLog stores one SLEEP_DURATION row per +// stage segment with `measuredAt = stage END` (so the night-total + hypnogram +// readers consume the same enum WHOOP / Apple write). The stage labels are +// harmonised onto the shared `SleepStage` enum. + +/** Google Health sleep stage label → HealthLog `SleepStage`. */ +const GOOGLE_HEALTH_SLEEP_STAGE_MAP: Record = { + // Canonical Google Health stage names (snake / lower variants accepted). + in_bed: "IN_BED", + inbed: "IN_BED", + awake: "AWAKE", + wake: "AWAKE", + light: "CORE", // "light" ↔ Apple "core" (same shallow-NREM band) + core: "CORE", + rem: "REM", + deep: "DEEP", + // The classic (non-stages) sleep log uses asleep/restless/wake. + asleep: "ASLEEP", + restless: "AWAKE", +} as const; + +/** + * Normalise a raw Google sleep-stage label to a `SleepStage`, or null for an + * unknown label (skipped rather than mis-bucketed). + */ +export function mapGoogleHealthSleepStage( + raw: unknown, +): GoogleHealthSleepStage | null { + if (typeof raw !== "string") return null; + const key = raw + .trim() + .toLowerCase() + .replace(/[\s-]+/g, "_"); + return ( + GOOGLE_HEALTH_SLEEP_STAGE_MAP[key] ?? + GOOGLE_HEALTH_SLEEP_STAGE_MAP[key.replace(/_/g, "")] ?? + null + ); +} + +/** One sleep-stage segment pulled defensively off a Google sleep session. */ +interface GoogleHealthSleepSegment { + stage: string; + startTime?: string; + endTime?: string; +} + +/** Minutes between two ISO instants, or null if either is unparseable. */ +function minutesBetween(startIso?: string, endIso?: string): number | null { + if (!startIso || !endIso) return null; + const s = new Date(startIso).getTime(); + const e = new Date(endIso).getTime(); + if (Number.isNaN(s) || Number.isNaN(e) || e <= s) return null; + return (e - s) / 60_000; +} + +/** + * Read the per-stage segments off a Google sleep `DataPoint`. Documented shape: + * `sleep.stages` is an array of + * `{ startTime, startUtcOffset, endTime, endUtcOffset, type }` where `type` is + * the `SLEEP_STAGE_TYPE` enum (`AWAKE | LIGHT | DEEP | REM | ASLEEP | RESTLESS + * | SLEEP_STAGE_TYPE_UNSPECIFIED`) and the times are RFC-3339 Timestamps. + */ +function readSleepSegments( + point: GoogleHealthDataPoint, +): GoogleHealthSleepSegment[] { + const stages = readPath(point, "sleep.stages"); + if (!Array.isArray(stages)) return []; + const out: GoogleHealthSleepSegment[] = []; + for (const raw of stages) { + if (!raw || typeof raw !== "object") continue; + const o = raw as Record; + const stage = typeof o.type === "string" ? o.type : ""; + const startTime = typeof o.startTime === "string" ? o.startTime : undefined; + const endTime = typeof o.endTime === "string" ? o.endTime : undefined; + if (stage) out.push({ stage, startTime, endTime }); + } + return out; +} + +/** + * The stable session anchor for a sleep `DataPoint`'s externalId. + * + * Prefers the Google resource id (`name`) — it is INVARIANT across Google's + * after-the-fact re-scoring of a night, so the per-segment rows keep the SAME + * externalId on a re-fetch and overwrite in place instead of minting parallel + * duplicates. This mirrors `mapWorkout`, which anchors on `name` for exactly + * this reason. + * + * The session interval end/start is only a FALLBACK (for a payload without a + * resource name): it SHIFTS whenever Google refines the wake/onset instant on a + * re-score, which is precisely what used to duplicate a night's rows. Paths are + * camelCase (`sleep.interval.endTime`) — the response payload never uses + * snake_case. + */ +function sleepSessionAnchor(point: GoogleHealthDataPoint, tz?: string): string { + const name = readPath(point, "name"); + if (typeof name === "string" && name !== "") return name; + const end = readPath(point, "sleep.interval.endTime"); + if (typeof end === "string") { + const d = parseLocalInstant(end, tz); + if (d) return d.toISOString(); + } + const start = readPath(point, "sleep.interval.startTime"); + if (typeof start === "string") { + const d = parseLocalInstant(start, tz); + if (d) return d.toISOString(); + } + return new Date(0).toISOString(); +} + +/** + * One mapped Google sleep session: the stable anchor, the session's covered + * time window (earliest segment start → latest segment end, both UTC), and the + * per-segment rows. The window drives the sync's replace-by-window cleanup of + * stale rows a re-score orphaned; `rows` is what gets upserted. + */ +export interface GoogleHealthSleepSession { + /** Stable session anchor — also the `:sleep:` externalId prefix. */ + anchor: string; + /** Earliest segment start across the session (UTC), or null if none map. */ + windowStart: Date | null; + /** Latest segment end across the session (UTC), or null if none map. */ + windowEnd: Date | null; + rows: GoogleHealthMappedMeasurement[]; +} + +/** + * Map one Google sleep session into per-SEGMENT `SLEEP_DURATION` rows plus its + * covered window. The Google sleep payload carries a real per-stage segment + * series (each with its own start/end), so one row is emitted PER SEGMENT — + * `measuredAt = that segment's END`. The timeline is MEASURED (real onsets), so + * these rows are NOT flagged reconstructed — unlike WHOOP, which has no onsets. + * + * Each segment's fieldTag keys off the STABLE session anchor plus the segment's + * own START instant — `:sleep:` — NOT a positional index + * and NOT the stage label. This is the fix for the re-score duplication: a + * positional index renumbered (and a stage-scoped tag changed) whenever Google + * re-scored a night, minting fresh externalIds so the upsert created parallel + * duplicate rows the night-total then double-counted. A segment's start instant + * is stable per block, and dropping the stage from the key means a mere + * re-classification (LIGHT→DEEP on the same block) UPDATES the row in place + * rather than orphaning it. Unknown stage labels are skipped; a session with no + * parseable segment yields an empty `rows` (and a null window). + * + * Segment timestamps can arrive OFFSET-LESS (local wall clock); `tz` (the user's + * stored zone) anchors them to the correct UTC instant rather than the process + * zone — without it a non-UTC user's near-midnight segment END would shift by + * their offset. Timestamps carrying an explicit offset/`Z` are honoured as-is. + */ +export function mapSleepSessionDetailed( + point: GoogleHealthDataPoint, + tz?: string, +): GoogleHealthSleepSession { + const anchor = sleepSessionAnchor(point, tz); + const segments = readSleepSegments(point); + const rows: GoogleHealthMappedMeasurement[] = []; + let windowStart: Date | null = null; + let windowEnd: Date | null = null; + + for (const seg of segments) { + const stage = mapGoogleHealthSleepStage(seg.stage); + if (!stage) continue; + const mins = minutesBetween(seg.startTime, seg.endTime); + if (mins === null || !(mins > 0)) continue; + const start = parseLocalInstant(seg.startTime as string, tz); + const end = parseLocalInstant(seg.endTime as string, tz); + if (!start || !end) continue; + if (!windowStart || start < windowStart) windowStart = start; + if (!windowEnd || end > windowEnd) windowEnd = end; + rows.push({ + type: "SLEEP_DURATION", + value: round2(mins), + unit: "minutes", + measuredAt: end, + fieldTag: `${anchor}:sleep:${start.toISOString()}`, + sleepStage: stage, + }); + } + + return { anchor, windowStart, windowEnd, rows }; +} + +/** + * Flat convenience wrapper — the per-segment rows of one session, without the + * window metadata. Retained for callers that only need the rows. + */ +export function mapSleepSession( + point: GoogleHealthDataPoint, + tz?: string, +): GoogleHealthMappedMeasurement[] { + return mapSleepSessionDetailed(point, tz).rows; +} + +// ── Workouts (exercise sessions) ─────────────────────────────── + +/** + * Google Health `Exercise.exerciseType` → HealthLog `WorkoutSportType`. The + * enum arrives UPPERCASE (`RUNNING`, `STRENGTH_TRAINING`, …); the resolver + * lowercases + underscores before the lookup, so the keys here are the + * normalised forms. Unknown types fall through to a generic label; the column + * is free-text so an unmapped type still persists (just not under a canonical + * sport bucket). + */ +const GOOGLE_HEALTH_EXERCISE_TYPE_MAP: Record = { + walk: "walking", + walking: "walking", + treadmill_walk: "walking", + incline_walk: "walking", + power_walking: "walking", + nordic_walking: "walking", + stroller_walk: "walking", + walk_with_weights: "walking", + run: "running", + running: "running", + treadmill: "running", + treadmill_running: "running", + trail_run: "running", + incline_run: "running", + bike: "cycling", + biking: "cycling", + cycling: "cycling", + spinning: "cycling", + mountain_biking: "cycling", + mountain_bike: "cycling", + outdoor_bike: "cycling", + stationary_bike: "cycling", + electric_bike: "cycling", + assault_bike: "cycling", + hike: "hiking", + hiking: "hiking", + backpacking: "hiking", + rucking: "hiking", + swim: "swimming", + swimming: "swimming", + swimming_pool: "swimming", + swimming_open_water: "swimming", + rowing: "rowing", + rowing_machine: "rowing", + elliptical: "elliptical", + stairclimber: "stairClimber", + stair_climbing: "stairClimber", + step_training: "stairClimber", + yoga: "yoga", + pilates: "mindAndBody", + meditate: "mindAndBody", + tai_chi: "mindAndBody", + stretching: "mindAndBody", + weights: "strength", + strength: "strength", + strength_training: "strength", + functional_strength_training: "strength", + weightlifting: "strength", + powerlifting: "strength", + free_weights: "strength", + body_weight: "strength", + resistance_bands: "strength", + calisthenics: "strength", + core_training: "strength", + workout: "strength", + hiit: "hiit", + high_intensity_interval_training: "hiit", + interval_workout: "hiit", + interval_training: "hiit", + tabata_workout: "hiit", + dance: "dance", + dancing: "dance", + golf: "golf", + tennis: "tennis", + basketball: "basketball", + soccer: "soccer", + football: "soccer", + bootcamp: "crossTraining", + circuit_training: "crossTraining", + crossfit: "crossTraining", + cross_training: "crossTraining", + aerobic_workout: "mixedCardio", + cardio_workout: "mixedCardio", + cardio_sculpt: "mixedCardio", + sport: "mixedCardio", +} as const; + +/** Resolve a Google exercise activity type to a canonical sport label. */ +export function mapGoogleHealthSportType(raw: unknown): string { + if (typeof raw !== "string" || raw.trim() === "") return "other"; + const key = raw + .trim() + .toLowerCase() + .replace(/[\s-]+/g, "_"); + return ( + GOOGLE_HEALTH_EXERCISE_TYPE_MAP[key] ?? + GOOGLE_HEALTH_EXERCISE_TYPE_MAP[key.replace(/_/g, "")] ?? + "other" + ); +} + +/** One mapped Google exercise session destined for a `Workout` row. */ +export interface GoogleHealthMappedWorkout { + externalId: string; + sportType: string; + startedAt: Date; + endedAt: Date; + durationSec: number; + totalEnergyKcal: number | null; + totalDistanceM: number | null; + avgHeartRate: number | null; + maxHeartRate: number | null; + minHeartRate: number | null; +} + +/** + * Read a finite number off a list of candidate paths (any sign), coercing int64 + * JSON strings, or null. + */ +function readNumber( + point: GoogleHealthDataPoint, + paths: string[], +): number | null { + for (const path of paths) { + const n = coerceNumber(readPath(point, path)); + if (n !== null) return n; + } + return null; +} + +/** + * Read an instant off a list of candidate paths, or null. Offset-less strings + * resolve against `tz` (the user's stored zone) rather than the process zone; + * strings carrying an explicit offset/`Z` are honoured as-is. + */ +function readInstant( + point: GoogleHealthDataPoint, + paths: string[], + tz?: string, +): Date | null { + for (const path of paths) { + const v = readPath(point, path); + if (typeof v === "string") { + const d = parseLocalInstant(v, tz); + if (d) return d; + } + } + return null; +} + +/** + * Map one Google exercise session `DataPoint` into a `Workout` shape. Returns + * null when there is no usable start/end (a session with no time span is not a + * workout). + * + * Documented payload: `exercise.interval.startTime/endTime` (RFC-3339), + * `exercise.exerciseType` (UPPERCASE enum), and + * `exercise.metricsSummary.{caloriesKcal, distanceMillimeters, + * averageHeartRateBeatsPerMinute (int64 string), …}`. There is no session-id + * field — the DataPoint's top-level `name` (a resource name) is the stable id; + * the start instant is the fallback. metricsSummary carries no max/min HR → + * null. + * + * Session start/end can arrive OFFSET-LESS (local wall clock); `tz` anchors + * them to the correct UTC instant rather than the process zone. + */ +export function mapWorkout( + point: GoogleHealthDataPoint, + tz?: string, +): GoogleHealthMappedWorkout | null { + const k = GOOGLE_HEALTH_DATA_TYPES.exercise.key; + const startedAt = readInstant(point, [`${k}.interval.startTime`], tz); + const endedAt = readInstant(point, [`${k}.interval.endTime`], tz); + if (!startedAt || !endedAt || endedAt <= startedAt) return null; + + const durationSec = Math.round( + (endedAt.getTime() - startedAt.getTime()) / 1000, + ); + + const name = readPath(point, "name"); + const externalId = + typeof name === "string" && name !== "" + ? name + : `exercise:${startedAt.toISOString()}`; + + const sportRaw = readPath(point, `${k}.exerciseType`); + + const energyKcal = readNumber(point, [`${k}.metricsSummary.caloriesKcal`]); + const distanceMm = readNumber(point, [ + `${k}.metricsSummary.distanceMillimeters`, + ]); + const avgHr = readNumber(point, [ + `${k}.metricsSummary.averageHeartRateBeatsPerMinute`, + ]); + + return { + externalId, + sportType: mapGoogleHealthSportType(sportRaw), + startedAt, + endedAt, + durationSec, + totalEnergyKcal: energyKcal !== null ? Math.round(energyKcal) : null, + totalDistanceM: + distanceMm !== null && distanceMm >= 0 ? round2(distanceMm / 1000) : null, + avgHeartRate: avgHr !== null && avgHr > 0 ? Math.round(avgHr) : null, + // metricsSummary carries no maximum/minimum heart rate. + maxHeartRate: null, + minHeartRate: null, + }; +} diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 884eaf65d..3993ba91d 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -80,58 +80,18 @@ import { hasActiveConsentForSurface, } from "@/lib/ai/consent-guard"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; -import { - enqueueStatusGeneration, - type InsightStatusScope, -} from "@/lib/jobs/insight-status-generate-shared"; -import { normalizeLocale, stripJsonFences } from "@/lib/insights/status-shared"; -import { isTimeoutStub, statusCacheAction } from "@/lib/insights/status-cache"; +import { stripJsonFences } from "@/lib/insights/status-shared"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; -import { - metricIdForMeasurementType, - metricStatusScope, -} from "@/lib/insights/metric-status-registry"; import { annotate } from "@/lib/logging/context"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout"; import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker"; -const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +// Scope invalidation + refill moved to a sibling module; re-exported so +// every existing call site keeps importing from here. +export * from "@/lib/insights/status-invalidation"; -/** - * Minimum gap between a scope's last cached (re)generation and the next - * ingest-driven re-enqueue. A scope whose cached assessment was - * (re)generated within this window is left intact on a fresh measurement - * ingest — not re-enqueued. - * - * A constantly-syncing client (Apple Health drips batches every few - * minutes) used to delete + re-enqueue every dirtied scope on every batch, - * and because each delete dropped the cache row the per-(user,scope,locale) - * enqueue singleton (120 s) could not coalesce across batches that arrive - * minutes apart — so STEPS / general / a metric card regenerated several - * times an hour and the assessment felt "regenerated on every visit". This - * window coalesces the storm: a genuinely stale scope (no fresh assessment) - * still refreshes immediately, but a scope refreshed inside the window is - * skipped, so a fresh assessment survives the day's sync drip. - * - * v1.16.8 — first widened from 30 min to 6 h to bound provider spend, but - * a 6 h wall meant same-day data was not narrated same-day: the nightly - * warm at 04:30 restarted the window, so a notable 08:00 reading never - * re-enqueued its scopes and the card showed the pre-reading text until - * the next nightly tick. The window is now a ONE-HOUR minimum gap, and - * the budget role the 6 h wall carried moves to the worker's content-hash - * gate: every ingest that lands past the gap re-enqueues the dirtied - * scopes, the worker's forced run re-gathers the snapshot, and the gate - * (`refreshUnchangedStatusInsight`) turns an unchanged snapshot into a - * timestamp refresh with zero provider cost. So this clock only bounds - * the SQL-gather frequency — at most one worker run per scope per hour - * under a constant sync drip — while provider spend tracks actual data - * change, which is what the gate exists to meter. Comparing the hash - * inline at invalidation time was rejected: it would run the full - * per-scope data gather inside the ingest path, which is exactly the - * work the queue exists to keep off that path. - */ -const INGEST_INVALIDATE_MIN_GAP_MS = 60 * 60 * 1000; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ "anthropometrics", @@ -446,251 +406,6 @@ export async function buildComparisonSnapshotForUser( return { baseline, metrics }; } -/** - * The seven per-metric assessment scopes. Each generator persists its - * cached text under `insights.-status.`; the nightly warm - * pass refreshes them through each generator's content-hash gate, and the - * targeted invalidator below re-enqueues only the scopes a fresh - * measurement of a given type actually dirties. - */ -export const PER_STATUS_SCOPES = [ - "blood-pressure", - "pulse", - "weight", - "bmi", - "mood", - "medication-compliance", - "general", -] as const; - -export type PerStatusScope = (typeof PER_STATUS_SCOPES)[number]; - -/** - * Map a measurement type to the assessment scopes a fresh reading of it - * dirties. `general` is the catch-all overview, so every measurement - * type touches it. BMI rides on WEIGHT (it is weight ÷ height²), so a - * new weight reading invalidates both the weight and the BMI card. - */ -function statusScopesForMeasurementType( - type: MeasurementType, -): PerStatusScope[] { - switch (type) { - case "WEIGHT": - return ["weight", "bmi", "general"]; - case "BLOOD_PRESSURE_SYS": - case "BLOOD_PRESSURE_DIA": - return ["blood-pressure", "general"]; - case "PULSE": - case "RESTING_HEART_RATE": - return ["pulse", "general"]; - default: - // Every other tracked metric (body composition, sleep, steps, - // glucose, …) still feeds the general overview assessment. - return ["general"]; - } -} - -/** - * Re-warm the cached per-metric assessments that a batch of fresh - * measurements dirties, so the next mount (or the next nightly warm - * pass) reflects the new data instead of serving the pre-measurement - * text for the rest of the day. - * - * Fire-and-forget from the measurement ingest path — idempotent and - * never a blocker on the user's write. v1.16.8 — the invalidator no - * longer DELETES the cache rows: the worker regenerates each enqueued - * scope with `force: true`, and the generator's content-hash gate - * decides whether the data actually changed. Keeping the row intact - * preserves the stale-while-revalidate read AND lets the gate skip the - * LLM entirely when the dirtying batch turned out to be a re-sync of - * known data. - */ -export async function invalidateStatusInsightsForTypes( - userId: string, - types: Iterable, -): Promise { - // One ordered set of every scope the batch dirties. The seven specialised - // scopes are bare slugs (`weight`, `general`, …); the generic HealthKit - // cards (v1.8.7.1) carry a `metric:` prefix. Both share the cache-key - // shape `insights.-status.`, so a single set covers the - // eviction + the debounce filter + the enqueue uniformly. Only registered, - // data-bearing types contribute a generic scope; the seven specialised - // metrics and any unregistered type resolve to null and are skipped, so - // the constant sync cannot fan out to unwanted scopes. - const scopes = new Set(); - for (const type of types) { - for (const scope of statusScopesForMeasurementType(type)) { - scopes.add(scope); - } - const metricId = metricIdForMeasurementType(type); - if (metricId) { - scopes.add(metricStatusScope(metricId)); - } - } - if (scopes.size === 0) return; - - // v1.8.7 — regenerate only the user's resolved locale, matching the - // read-path (every `*-status` GET serves `normalizeLocale(user.locale)`). - // Warming both locales doubled provider spend on every sync, half of it - // for a language the user never opens. A second locale a client actually - // reads warms lazily through the read-path enqueue on its first miss. - const localeRow = await prisma.user.findUnique({ - where: { id: userId }, - select: { locale: true }, - }); - const locale = normalizeLocale(localeRow?.locale); - - // v1.9.0 — debounce the ingest-invalidation storm. A constantly-syncing - // client drips batches every few minutes; re-enqueuing every dirtied - // scope on every batch regenerated the same card several times an hour - // (the per-(user,scope,locale) enqueue singleton is only 120 s, so it - // could not coalesce across batches arriving minutes apart). Skip any - // scope whose cached assessment was (re)generated within the minimum - // gap — leave its row intact and do NOT re-enqueue. A genuinely stale - // or missing scope still refreshes immediately, so correctness holds; - // only the redundant churn is removed. Past the gap the enqueue always - // goes through — the worker's content-hash gate decides whether the - // batch actually changed anything (see the constant's doc). - const freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes); - const staleScopes = Array.from(scopes).filter( - (scope) => !freshScopes.has(scope), - ); - if (staleScopes.length === 0) { - annotate({ - action: { name: "insights.status.invalidate.debounced" }, - meta: { skipped: scopes.size, refreshed: 0 }, - }); - return; - } - - // v1.8.7 — regenerate-on-invalidate: enqueue a debounced regenerate for - // each dirtied scope so the cache is re-warmed in the background. The - // enqueue is coalesced per `(user, metric, locale)` via the queue's - // `singletonKey` (120 s window); the debounce above is the second, wider - // coalescing layer that survives across the sync drip. The cache row - // stays in place (stale-while-revalidate keeps the previous assessment - // visible) — the worker forces the generator, whose content-hash gate - // skips the LLM when the batch did not actually change the snapshot. - for (const scope of staleScopes) { - void enqueueStatusGeneration({ userId, metric: scope, locale }); - } - - annotate({ - action: { name: "insights.status.invalidate.debounced" }, - meta: { skipped: freshScopes.size, refreshed: staleScopes.length }, - }); -} - -/** - * v1.16.8 — enqueue a hash-gated refill of every assessment card one user - * actually has: the seven specialised scopes plus the generic - * `metric:` scope of every measurement type with live rows. The - * worker regenerates each enqueued scope with `force: true`, so every - * card skips its same-day cache read, re-gathers its snapshot, and lands - * on the content-hash gate — a card whose data changed regenerates, an - * unchanged card gets a free timestamp refresh. - * - * This is the manual-regenerate path's card story: the POST regenerate - * used to blanket-evict every per-status row, which deleted the hash - * baselines and force-paid ~45 regenerations per click. Enqueuing through - * the gate keeps the baseline rows intact, so a user who noticed a stale - * card gets exactly the changed cards re-narrated — and nothing else. - * Deliberately NOT routed through the ingest debounce: an explicit - * regenerate is a user action, already bounded by the route's hourly - * rate limit and the queue's per-(user,scope,locale) singleton. - * - * Returns the number of scopes enqueued (best-effort — the generic-scope - * discovery read failing still refills the seven specialised cards). - */ -export async function enqueueStatusRefillForUser( - userId: string, - locale: "de" | "en", -): Promise { - const scopes = new Set(PER_STATUS_SCOPES); - try { - // `groupBy` compiles to a server-side `GROUP BY` — Prisma's - // `distinct` dedups in the client AFTER pulling every live row, which - // on a dense multi-year account walks a six-figure row set to answer - // "which types exist?" on the request path. - const rows = await prisma.measurement.groupBy({ - by: ["type"], - where: { userId, deletedAt: null }, - }); - for (const row of rows) { - const metricId = metricIdForMeasurementType(row.type); - if (metricId) scopes.add(metricStatusScope(metricId)); - } - } catch { - // Discovery is best-effort; the specialised scopes still refill. - } - for (const scope of scopes) { - void enqueueStatusGeneration({ userId, metric: scope, locale }); - } - return scopes.size; -} - -/** - * v1.9.0 — return the subset of `scopes` whose cached assessment for - * `locale` was generated within `INGEST_INVALIDATE_MIN_GAP_MS` and is a - * real (non-stub) assessment. Those scopes are skipped by the ingest - * invalidator so a fresh assessment survives the sync drip. - * - * One indexed read per user (a single `findMany` over this user's recent - * status-cache rows, newest-first) answers it for every candidate scope at - * once — cheaper than a per-scope probe and bounded by `take`. A timeout - * stub never counts as fresh (it carries no real assessment), so a scope - * that recently stalled still gets a retry enqueued. - */ -async function findRecentlyWarmedScopes( - userId: string, - locale: "de" | "en", - scopes: ReadonlySet, -): Promise> { - const cutoff = new Date(Date.now() - INGEST_INVALIDATE_MIN_GAP_MS); - // Match exactly the cache actions for the candidate scopes in this locale. - const candidateActions = Array.from(scopes, (scope) => - statusCacheAction(scope, locale), - ); - const actionToScope = new Map(); - for (const scope of scopes) { - actionToScope.set(statusCacheAction(scope, locale), scope); - } - - const rows = await prisma.auditLog.findMany({ - where: { - userId, - action: { in: candidateActions }, - createdAt: { gte: cutoff }, - }, - orderBy: { createdAt: "desc" }, - select: { action: true, details: true }, - }); - - const fresh = new Set(); - for (const row of rows) { - const scope = actionToScope.get(row.action); - if (!scope || fresh.has(scope)) continue; - if (!row.details) continue; - try { - const parsed = JSON.parse(row.details) as { - model?: string; - timeout?: boolean; - text?: string; - }; - // A stub is not a real assessment — let a stalled scope retry. - if (isTimeoutStub(parsed)) continue; - if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { - continue; - } - fresh.add(scope); - } catch { - // Malformed payload — treat as not-fresh so the scope refreshes. - continue; - } - } - return fresh; -} - interface GenerateOptions { /** Resolved UI locale for the prompt + cache row. */ locale: "de" | "en"; diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts new file mode 100644 index 000000000..32754a075 --- /dev/null +++ b/src/lib/insights/feature-blocks.ts @@ -0,0 +1,385 @@ +/** + * Briefing-scoped feature read blocks: full-history extremes, the + * bucketed rollup series, and the v1.22 cross-signal integration blocks + * (flagged labs, preventive-care due/overdue, workout aggregate). Each + * is one bounded DB read projecting to its slice of the + * `AggregatedFeatures` payload. + * + * Extracted verbatim from `features.ts`, which re-exports this module + * so every existing call site keeps importing from there; the assembly + * point (`extractFeatures`) stays in the hub. + */ +import { prisma } from "@/lib/db"; +import { classifyReferenceRange } from "@/lib/labs/reference-range"; +import { resolveLabFields } from "@/lib/labs/serialise"; +import { readRollupBuckets } from "@/lib/rollups/measurement-rollups"; +import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation"; +import type { + AggregatedFeatures, + BucketedSeries, +} from "@/lib/insights/features"; +import type { + MeasurementType, + RollupGranularity, +} from "@/generated/prisma/client"; + +/** All-time aggregate (full history) for one measurement type. */ +interface AllTimeExtremes { + mean: number | null; + min: number | null; + max: number | null; +} + +/** + * v1.18.11 P1 — full-history min / max / mean per measurement type via ONE + * grouped SQL aggregation, with NO row materialisation in JS. Used to fill the + * `allTime*` feature fields honestly when the bulk feature read is bounded to a + * recent window: the windowed `summarize()` covers trends + recent windows, and + * this covers the long-horizon extremes the prompt labels "allTime". + * + * Only the four types that expose `allTime*` fields are aggregated (weight, + * systolic, diastolic, pulse). Returns a map keyed by `MeasurementType`; a type + * with no rows is simply absent. + */ +export async function readAllTimeExtremes( + userId: string, + types: readonly MeasurementType[], +): Promise> { + const rows = await prisma.measurement.groupBy({ + by: ["type"], + where: { userId, deletedAt: null, type: { in: [...types] } }, + _avg: { value: true }, + _min: { value: true }, + _max: { value: true }, + }); + const out = new Map(); + for (const r of rows) { + out.set(r.type, { + mean: r._avg.value ?? null, + min: r._min.value ?? null, + max: r._max.value ?? null, + }); + } + return out; +} + +/** + * v1.4.36 W3 T1 — bucket-window definitions for the + * `bucketedMeasurements` payload. Mirrors the rollup populator's + * granularity ladder so the read-side picks up whatever the persistent + * table holds without a recompute round-trip. + * + * The 90 / 365 / 1825-day windows are non-overlapping: each row of + * `measurement_rollups` lives at exactly one granularity, and the + * downstream model reads the union of the three series per type. + * Total volume per type for a heavy power user (5 years of daily + * data): 90 DAY + 39 WEEK + 50 MONTH = 179 buckets × ~40 bytes JSON + * each ≈ 7 KB. Eight metric types lands at ~56 KB — well under the + * 5 MB cap above, vs 25.9 MB for the v1.4.35 rawMeasurements shape. + */ +const BUCKET_WINDOWS: Array<{ + granularity: RollupGranularity; + fromDays: number; + toDays: number; +}> = [ + { granularity: "DAY", fromDays: 0, toDays: 90 }, + { granularity: "WEEK", fromDays: 90, toDays: 365 }, + { granularity: "MONTH", fromDays: 365, toDays: 1825 }, +]; + +/** + * Types the bucketed payload covers. Mirrors the aggregate branches + * above so the model never sees a bucket for a metric whose aggregate + * block was suppressed. New `MeasurementType` enum values flow in by + * adding one row; the rollup populator already covers every type. + */ +// Derived from the signal registry: every signal flagged +// `surfaces.correlationEligible` projects to its DB `MeasurementType`. The list +// is a membership/iteration set (each type is read independently), so order is +// not significant; the registry-invariant test pins the set byte-for-byte. +const BUCKETED_TYPES: MeasurementType[] = deriveBucketedTypes(); + +/** Days a preventive-care item must be due within to surface as "due soon". */ +const PREVENTIVE_DUE_HORIZON_DAYS = 21; + +/** Cap on items surfaced per preventive-care bucket. */ +const PREVENTIVE_MAX_PER_BUCKET = 5; + +/** Cap on flagged biomarkers surfaced to the briefing. */ +const LABS_MAX_FLAGGED = 8; + +/** Only lab readings within this many months are considered "recent". */ +const LABS_LOOKBACK_MONTHS = 12; + +/** Trailing window (days) for the workout aggregate. */ +const WORKOUT_WINDOW_DAYS = 90; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Strip control chars + collapse whitespace, then bound the length, before a + * user-supplied label can reach the briefing prompt. Mirrors the labs / + * illness snapshot label handling — a self-scoped prompt-injection surface. + */ +function sanitizeLabel(text: string, max = 80): string { + let out = ""; + for (const ch of text) { + const code = ch.codePointAt(0) ?? 0; + out += code < 0x20 || (code >= 0x7f && code <= 0x9f) ? " " : ch; + } + return out.replace(/\s+/g, " ").trim().slice(0, max); +} + +/** + * v1.22 — recent FLAGGED biomarkers (abnormal or trending) for the briefing. + * Most-recent reading per biomarker over the lookback window; hidden markers + * excluded; qualitative rows neutral. Only abnormal (below/above) OR trending + * markers surface, bounded. Returns `undefined` when nothing is flagged so the + * block is omitted rather than emitting an empty shape. + */ +export async function readLabsBriefingBlock( + userId: string, + now: number, +): Promise { + const nowDate = new Date(now); + const cutoff = new Date(nowDate); + cutoff.setMonth(cutoff.getMonth() - LABS_LOOKBACK_MONTHS); + + const rows = await prisma.labResult.findMany({ + where: { userId, deletedAt: null, takenAt: { gte: cutoff, lte: nowDate } }, + orderBy: { takenAt: "desc" }, + take: LABS_MAX_FLAGGED * 16, + select: { + analyte: true, + panel: true, + unit: true, + value: true, + valueText: true, + referenceLow: true, + referenceHigh: true, + takenAt: true, + biomarkerId: true, + biomarker: { + select: { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + hidden: true, + }, + }, + }, + }); + if (rows.length === 0) return undefined; + + // Group rows per biomarker identity (linked id, else lower-cased analyte), + // newest-first, so we can read the latest reading + the immediately prior one + // for a trend. Hidden markers are dropped entirely. + const byMarker = new Map(); + for (const row of rows) { + if (row.biomarker?.hidden) continue; + const resolved = resolveLabFields(row, row.biomarker); + const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`; + const list = byMarker.get(key) ?? []; + list.push(row); + byMarker.set(key, list); + } + + const flagged: NonNullable["flagged"] = []; + for (const list of byMarker.values()) { + const latest = list[0]; + const resolved = resolveLabFields(latest, latest.biomarker); + const rangeStatus = + latest.value === null + ? ("unknown" as const) + : classifyReferenceRange( + latest.value, + resolved.referenceLow, + resolved.referenceHigh, + ); + + // Trend = latest numeric reading vs the immediately prior numeric reading. + let trend: "rising" | "falling" | "flat" | null = null; + if (latest.value !== null) { + const prior = list.find((r, i) => i > 0 && r.value !== null); + if (prior?.value != null) { + const delta = latest.value - prior.value; + const eps = Math.max(Math.abs(prior.value) * 0.02, 1e-9); + trend = delta > eps ? "rising" : delta < -eps ? "falling" : "flat"; + } + } + + const isAbnormal = rangeStatus === "below" || rangeStatus === "above"; + const isTrending = trend === "rising" || trend === "falling"; + if (!isAbnormal && !isTrending) continue; + + flagged.push({ + analyte: resolved.analyte, + value: latest.value, + valueText: latest.valueText ? sanitizeLabel(latest.valueText, 60) : null, + unit: resolved.unit, + rangeStatus, + trend, + takenAt: latest.takenAt.toISOString(), + daysAgo: Math.round((now - latest.takenAt.getTime()) / MS_PER_DAY), + }); + } + + if (flagged.length === 0) return undefined; + // Abnormal markers lead, then most-recent first. + flagged.sort((a, b) => { + const abn = (s: typeof a.rangeStatus) => + s === "below" || s === "above" ? 0 : 1; + const d = abn(a.rangeStatus) - abn(b.rangeStatus); + return d !== 0 ? d : a.daysAgo - b.daysAgo; + }); + return { + flagged: flagged.slice(0, LABS_MAX_FLAGGED), + flaggedCount: flagged.length, + }; +} + +/** + * v1.22 — preventive-care (Vorsorge) due + overdue read-side. Reads the + * user's enabled, live reminders and buckets by the server-authoritative + * `nextDueAt`. Returns `undefined` when nothing is due or overdue. + */ +export async function readPreventiveCareBlock( + userId: string, + now: number, +): Promise { + const horizon = new Date(now + PREVENTIVE_DUE_HORIZON_DAYS * MS_PER_DAY); + const rows = await prisma.measurementReminder.findMany({ + where: { + userId, + deletedAt: null, + enabled: true, + nextDueAt: { not: null, lte: horizon }, + }, + orderBy: { nextDueAt: "asc" }, + take: (PREVENTIVE_MAX_PER_BUCKET + 1) * 4, + select: { label: true, nextDueAt: true }, + }); + if (rows.length === 0) return undefined; + + const overdue: NonNullable["overdue"] = + []; + const due: NonNullable["due"] = []; + for (const r of rows) { + if (!r.nextDueAt) continue; + const label = sanitizeLabel(r.label); + if (!label) continue; + const diffMs = r.nextDueAt.getTime() - now; + if (diffMs < 0) { + overdue.push({ label, daysOverdue: Math.round(-diffMs / MS_PER_DAY) }); + } else { + due.push({ label, daysUntil: Math.round(diffMs / MS_PER_DAY) }); + } + } + if (overdue.length === 0 && due.length === 0) return undefined; + return { + overdue: overdue.slice(0, PREVENTIVE_MAX_PER_BUCKET), + due: due.slice(0, PREVENTIVE_MAX_PER_BUCKET), + }; +} + +/** + * v1.22 — workout aggregate over the trailing window. Provider-agnostic: + * counts + summed duration + summed distance (km, when any source reported it) + * over 7 / 30 days, plus the latest workout. Returns `undefined` when no + * workouts fall in the window. + */ +export async function readWorkoutsBlock( + userId: string, + now: number, +): Promise { + const since = new Date(now - WORKOUT_WINDOW_DAYS * MS_PER_DAY); + const rows = await prisma.workout.findMany({ + where: { userId, startedAt: { gte: since } }, + orderBy: { startedAt: "desc" }, + take: 2000, + select: { + sportType: true, + startedAt: true, + durationSec: true, + totalDistanceM: true, + }, + }); + if (rows.length === 0) return undefined; + + const tally = (windowDays: number) => { + const cutoff = now - windowDays * MS_PER_DAY; + let count = 0; + let durationSec = 0; + let distanceM = 0; + let anyDistance = false; + for (const w of rows) { + if (w.startedAt.getTime() < cutoff) continue; + count += 1; + durationSec += w.durationSec; + if (w.totalDistanceM != null) { + distanceM += w.totalDistanceM; + anyDistance = true; + } + } + return { + count, + totalDurationMin: Math.round(durationSec / 60), + totalDistanceKm: anyDistance + ? Math.round((distanceM / 1000) * 10) / 10 + : null, + }; + }; + + const newest = rows[0]; + const latest = { + sportType: sanitizeLabel(newest.sportType, 40), + daysAgo: Math.round((now - newest.startedAt.getTime()) / MS_PER_DAY), + durationMin: Math.round(newest.durationSec / 60), + distanceKm: + newest.totalDistanceM != null + ? Math.round((newest.totalDistanceM / 1000) * 10) / 10 + : null, + }; + + return { last7: tally(7), last30: tally(30), latest }; +} + +/** + * Read every BUCKETED_TYPES × BUCKET_WINDOWS combination from the + * persistent rollup table and project to the wire shape. Empty + * (type, granularity) combinations are dropped so the payload never + * carries a labelled-but-empty series. + */ +export async function readBucketedSeries( + userId: string, + now: number, +): Promise { + const series: BucketedSeries[] = []; + for (const type of BUCKETED_TYPES) { + for (const window of BUCKET_WINDOWS) { + const from = new Date(now - window.toDays * 24 * 60 * 60 * 1000); + const to = new Date(now - window.fromDays * 24 * 60 * 60 * 1000); + const rows = await readRollupBuckets( + userId, + type, + window.granularity, + from, + to, + ); + if (rows.length === 0) continue; + series.push({ + type, + granularity: window.granularity, + buckets: rows.map((r) => ({ + bucketStart: r.bucketStart.toISOString(), + mean: Math.round(r.mean * 100) / 100, + count: r.count, + })), + }); + } + } + return series; +} diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 5adcd5e64..11379b31e 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -4,7 +4,7 @@ * No raw timestamps or exact values are sent in aggregated mode. */ import { prisma } from "@/lib/db"; -import { summarize, trendSlope } from "@/lib/analytics/trends"; +import { summarize } from "@/lib/analytics/trends"; import type { DataPoint } from "@/lib/analytics/trends"; import { buildComplianceMedicationContext, @@ -26,18 +26,29 @@ import { type CorrelationResult, } from "@/lib/analytics/correlations"; import { getMedicationCategories } from "@/lib/medication-category"; -import { classifyReferenceRange } from "@/lib/labs/reference-range"; -import { resolveLabFields } from "@/lib/labs/serialise"; -import { readRollupBuckets } from "@/lib/rollups/measurement-rollups"; -import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation"; import { ensureUserMoodRollupsFresh, readMoodDayRollups, } from "@/lib/rollups/mood-rollups"; -import type { - MeasurementType, - RollupGranularity, -} from "@/generated/prisma/client"; +import { + readAllTimeExtremes, + readBucketedSeries, + readLabsBriefingBlock, + readPreventiveCareBlock, + readWorkoutsBlock, +} from "@/lib/insights/feature-blocks"; +import { + computeHistoricalComparison, + computeSignalsOfDay, + type SignalOfDay, +} from "@/lib/insights/signals-of-day"; +import type { RollupGranularity } from "@/generated/prisma/client"; + +// The briefing read blocks and the signals-of-day builder moved to +// sibling modules; re-exported so every existing call site keeps +// importing from here. +export * from "@/lib/insights/feature-blocks"; +export * from "@/lib/insights/signals-of-day"; interface DataCoverage { count: number; @@ -312,45 +323,6 @@ export interface AggregatedFeatures { }; } -/** - * v1.18.7 — one present-focused signal feeding the daily briefing. Every - * numeric field is pre-computed so the model states it rather than - * re-deriving the comparison (which small LLMs do unreliably). - */ -export interface SignalOfDay { - /** Briefing `sourceMetric` discriminator the UI pins an icon + route on. */ - metric: - "bp" | "weight" | "pulse" | "mood" | "sleep" | "resting_hr" | "glucose"; - /** Natural-language label (no enum leak into prose). */ - label: string; - /** Unit string when the metric carries one. */ - unit?: string; - /** Freshest reading (the "now" value the briefing leads with). */ - latest: number; - /** Days since the freshest reading. */ - latestDaysAgo: number; - /** Trailing-7d mean. */ - avg7: number | null; - /** Trailing-30d mean. */ - avg30: number | null; - /** Signed `latest − avg7`, pre-computed. */ - deltaVs7: number | null; - /** Signed `latest − avg30`, pre-computed. */ - deltaVs30: number | null; - /** Normal-swing SD over the trailing-30d window. */ - spread30: number | null; - /** `|latest − avg30| > spread30` — the significance verdict as a boolean. */ - outsideNormalSwing: boolean; - /** Emerging direction over the trailing 30 days (slope sign). */ - emergingTrend: "rising" | "falling" | "flat" | null; - /** A peak / trough inside the last 14 days, when one stands out. */ - recentAnomaly: { - kind: "peak" | "trough"; - value: number; - anomalyDaysAgo: number; - } | null; -} - /** * One bucketed series. `granularity` decides the bucket width (DAY for * the trailing 90-day window, WEEK for 90→365 days, MONTH for the @@ -408,46 +380,6 @@ export const FEATURES_MAX_BYTES = 5 * 1024 * 1024; */ export const BRIEFING_FEATURE_WINDOW_DAYS = 400; -/** All-time aggregate (full history) for one measurement type. */ -interface AllTimeExtremes { - mean: number | null; - min: number | null; - max: number | null; -} - -/** - * v1.18.11 P1 — full-history min / max / mean per measurement type via ONE - * grouped SQL aggregation, with NO row materialisation in JS. Used to fill the - * `allTime*` feature fields honestly when the bulk feature read is bounded to a - * recent window: the windowed `summarize()` covers trends + recent windows, and - * this covers the long-horizon extremes the prompt labels "allTime". - * - * Only the four types that expose `allTime*` fields are aggregated (weight, - * systolic, diastolic, pulse). Returns a map keyed by `MeasurementType`; a type - * with no rows is simply absent. - */ -async function readAllTimeExtremes( - userId: string, - types: readonly MeasurementType[], -): Promise> { - const rows = await prisma.measurement.groupBy({ - by: ["type"], - where: { userId, deletedAt: null, type: { in: [...types] } }, - _avg: { value: true }, - _min: { value: true }, - _max: { value: true }, - }); - const out = new Map(); - for (const r of rows) { - out.set(r.type, { - mean: r._avg.value ?? null, - min: r._min.value ?? null, - max: r._max.value ?? null, - }); - } - return out; -} - /** Round an all-time mean to 1 decimal, matching the windowed `summarize` mean. */ function roundMean(v: number | null): number | null { return v === null ? null : Math.round(v * 10) / 10; @@ -468,43 +400,7 @@ export class FeaturesPayloadTooLargeError extends Error { } } -/** - * v1.4.36 W3 T1 — bucket-window definitions for the - * `bucketedMeasurements` payload. Mirrors the rollup populator's - * granularity ladder so the read-side picks up whatever the persistent - * table holds without a recompute round-trip. - * - * The 90 / 365 / 1825-day windows are non-overlapping: each row of - * `measurement_rollups` lives at exactly one granularity, and the - * downstream model reads the union of the three series per type. - * Total volume per type for a heavy power user (5 years of daily - * data): 90 DAY + 39 WEEK + 50 MONTH = 179 buckets × ~40 bytes JSON - * each ≈ 7 KB. Eight metric types lands at ~56 KB — well under the - * 5 MB cap above, vs 25.9 MB for the v1.4.35 rawMeasurements shape. - */ -const BUCKET_WINDOWS: Array<{ - granularity: RollupGranularity; - fromDays: number; - toDays: number; -}> = [ - { granularity: "DAY", fromDays: 0, toDays: 90 }, - { granularity: "WEEK", fromDays: 90, toDays: 365 }, - { granularity: "MONTH", fromDays: 365, toDays: 1825 }, -]; - -/** - * Types the bucketed payload covers. Mirrors the aggregate branches - * above so the model never sees a bucket for a metric whose aggregate - * block was suppressed. New `MeasurementType` enum values flow in by - * adding one row; the rollup populator already covers every type. - */ -// Derived from the signal registry: every signal flagged -// `surfaces.correlationEligible` projects to its DB `MeasurementType`. The list -// is a membership/iteration set (each type is read independently), so order is -// not significant; the registry-invariant test pins the set byte-for-byte. -const BUCKETED_TYPES: MeasurementType[] = deriveBucketedTypes(); - -function stdDev(values: number[]): number | null { +export function stdDev(values: number[]): number | null { if (values.length < 2) return null; const mean = values.reduce((s, v) => s + v, 0) / values.length; const variance = @@ -512,7 +408,7 @@ function stdDev(values: number[]): number | null { return Math.round(Math.sqrt(variance) * 10) / 10; } -function toDataPoints( +export function toDataPoints( records: Array<{ value: number; measuredAt: Date }>, ): DataPoint[] { return records.map((r) => ({ date: r.measuredAt, value: r.value })); @@ -548,7 +444,7 @@ function computeCoverage( } /** Compute average of values within a time window (days ago from now). */ -function avgInWindow( +export function avgInWindow( records: Array<{ value: number; measuredAt: Date }>, now: number, fromDaysAgo: number, @@ -565,193 +461,6 @@ function avgInWindow( return Math.round((sum / filtered.length) * 100) / 100; } -/** Compute historical comparison: current 7d avg vs previous 30d avg (days 7-37). */ -function computeHistoricalComparison( - records: Array<{ value: number; measuredAt: Date }>, - now: number, -): { - current7dAvg: number | null; - previous30dAvg: number | null; - change: number | null; -} { - const current7dAvg = avgInWindow(records, now, 7, 0); - const previous30dAvg = avgInWindow(records, now, 37, 7); - const change = - current7dAvg !== null && previous30dAvg !== null - ? Math.round((current7dAvg - previous30dAvg) * 100) / 100 - : null; - return { current7dAvg, previous30dAvg, change }; -} - -/** - * v1.18.7 — clinical priority order for the signals block. BP / glucose - * lead, then resting HR / pulse, then weight, then mood. - * Lower index = higher priority; the briefing surfaces the top ≤3. - */ -const SIGNAL_PRIORITY: SignalOfDay["metric"][] = [ - "bp", - "glucose", - "resting_hr", - "pulse", - "weight", - "mood", -]; - -/** Round helper local to the signals builder. */ -function r2(v: number): number { - return Math.round(v * 100) / 100; -} - -/** - * Build one signal from a single metric's measurement records. Returns null - * when there is no fresh reading or fewer than three points in 30 days — a - * sparse metric cannot carry an honest "today vs your normal" read. - */ -function buildSignal( - metric: SignalOfDay["metric"], - label: string, - records: Array<{ value: number; measuredAt: Date }>, - now: number, - unit?: string, -): SignalOfDay | null { - if (records.length === 0) return null; - const newest = records[records.length - 1]; - const latestDaysAgo = Math.round( - (now - newest.measuredAt.getTime()) / (24 * 60 * 60 * 1000), - ); - // A reading older than two weeks is not a "signal of the day". - if (latestDaysAgo > 14) return null; - - const win30 = records.filter( - (rec) => rec.measuredAt.getTime() >= now - 30 * 24 * 60 * 60 * 1000, - ); - if (win30.length < 3) return null; - - const avg7 = avgInWindow(records, now, 7); - const avg30 = avgInWindow(records, now, 30); - const spread30 = stdDev(win30.map((rec) => rec.value)); - const deltaVs7 = avg7 !== null ? r2(newest.value - avg7) : null; - const deltaVs30 = avg30 !== null ? r2(newest.value - avg30) : null; - const outsideNormalSwing = - avg30 !== null && spread30 !== null && spread30 > 0 - ? Math.abs(newest.value - avg30) > spread30 - : false; - - const slope = trendSlope(toDataPoints(records), 30, now); - const emergingTrend: SignalOfDay["emergingTrend"] = slope - ? slope.direction === "up" - ? "rising" - : slope.direction === "down" - ? "falling" - : "flat" - : null; - - // Recent anomaly: an extreme inside the last 14 days vs the 30d mean ± 2 SD. - let recentAnomaly: SignalOfDay["recentAnomaly"] = null; - // Track the RAW extreme magnitude — comparing against the already-r2() - // rounded stored value can drop a genuinely larger anomaly. - let bestAbs = 0; - if (avg30 !== null && spread30 !== null && spread30 > 0) { - const recent = records.filter( - (rec) => rec.measuredAt.getTime() >= now - 14 * 24 * 60 * 60 * 1000, - ); - for (const rec of recent) { - const sd = (rec.value - avg30) / spread30; - if (Math.abs(sd) >= 2) { - const abs = Math.abs(rec.value - avg30); - if (recentAnomaly === null || abs > bestAbs) { - bestAbs = abs; - recentAnomaly = { - kind: (sd > 0 ? "peak" : "trough") as "peak" | "trough", - value: r2(rec.value), - anomalyDaysAgo: Math.round( - (now - rec.measuredAt.getTime()) / (24 * 60 * 60 * 1000), - ), - }; - } - } - } - } - - return { - metric, - label, - ...(unit ? { unit } : {}), - latest: r2(newest.value), - latestDaysAgo, - avg7, - avg30, - deltaVs7, - deltaVs30, - spread30, - outsideNormalSwing, - emergingTrend, - recentAnomaly, - }; -} - -/** - * v1.18.7 — assemble the present-focused "Signals of the day" block from the - * in-memory measurement set (no extra DB round-trip). Computes today-vs-7d/30d - * deltas, an emerging slope, and a recent anomaly per salient metric, then - * returns the top ≤3 ranked by clinical priority. Salient signals - * (outside-normal-swing or a recent anomaly) bubble above quiet ones inside - * each priority tier so the briefing leads with what actually moved. - */ -function computeSignalsOfDay( - byType: (type: string) => Array<{ value: number; measuredAt: Date }>, - now: number, -): SignalOfDay[] { - const candidates: SignalOfDay[] = []; - const push = (s: SignalOfDay | null) => { - if (s) candidates.push(s); - }; - - // Systolic carries the BP signal (the headline number clinicians read first). - push( - buildSignal( - "bp", - "blood pressure (systolic)", - byType("BLOOD_PRESSURE_SYS"), - now, - "mmHg", - ), - ); - // Glucose uses the canonical stored value (the model reads the snapshot, - // not display units); absent data simply produces no signal. - push(buildSignal("glucose", "blood glucose", byType("BLOOD_GLUCOSE"), now)); - push( - buildSignal( - "resting_hr", - "resting heart rate", - byType("RESTING_HEART_RATE"), - now, - "bpm", - ), - ); - push(buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm")); - push(buildSignal("weight", "weight", byType("WEIGHT"), now, "kg")); - // Sleep is stored one row per stage per night, so a raw "latest" point - // would mis-sum; the sleep aggregates carry that signal already. Steps - // ingest as many intraday `stats:`-prefixed samples, so the newest raw row - // is a partial-day fragment, not a daily total — excluded like sleep. - - const priorityIndex = (m: SignalOfDay["metric"]) => { - const idx = SIGNAL_PRIORITY.indexOf(m); - return idx === -1 ? SIGNAL_PRIORITY.length : idx; - }; - const salience = (s: SignalOfDay) => - (s.outsideNormalSwing ? 2 : 0) + (s.recentAnomaly ? 1 : 0); - - return candidates - .sort((a, b) => { - const sal = salience(b) - salience(a); - if (sal !== 0) return sal; - return priorityIndex(a.metric) - priorityIndex(b.metric); - }) - .slice(0, 3); -} - // ─── v1.22 — cross-signal integration blocks (briefing-scoped) ──────────── /** @@ -765,23 +474,6 @@ function computeSignalsOfDay( */ const INTEGRATION_BLOCK_MIN_WINDOW_DAYS = 180; -/** Days a preventive-care item must be due within to surface as "due soon". */ -const PREVENTIVE_DUE_HORIZON_DAYS = 21; - -/** Cap on items surfaced per preventive-care bucket. */ -const PREVENTIVE_MAX_PER_BUCKET = 5; - -/** Cap on flagged biomarkers surfaced to the briefing. */ -const LABS_MAX_FLAGGED = 8; - -/** Only lab readings within this many months are considered "recent". */ -const LABS_LOOKBACK_MONTHS = 12; - -/** Trailing window (days) for the workout aggregate. */ -const WORKOUT_WINDOW_DAYS = 90; - -const MS_PER_DAY = 24 * 60 * 60 * 1000; - /** * v1.25 — newest-first row cap on the bulk measurement read in * `extractFeatures`. Mirrors the Coach snapshot's `SNAPSHOT_MEASUREMENT_ROW_CAP` @@ -792,237 +484,6 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000; */ const FEATURE_MEASUREMENT_ROW_CAP = 6000; -/** - * Strip control chars + collapse whitespace, then bound the length, before a - * user-supplied label can reach the briefing prompt. Mirrors the labs / - * illness snapshot label handling — a self-scoped prompt-injection surface. - */ -function sanitizeLabel(text: string, max = 80): string { - let out = ""; - for (const ch of text) { - const code = ch.codePointAt(0) ?? 0; - out += code < 0x20 || (code >= 0x7f && code <= 0x9f) ? " " : ch; - } - return out.replace(/\s+/g, " ").trim().slice(0, max); -} - -/** - * v1.22 — recent FLAGGED biomarkers (abnormal or trending) for the briefing. - * Most-recent reading per biomarker over the lookback window; hidden markers - * excluded; qualitative rows neutral. Only abnormal (below/above) OR trending - * markers surface, bounded. Returns `undefined` when nothing is flagged so the - * block is omitted rather than emitting an empty shape. - */ -async function readLabsBriefingBlock( - userId: string, - now: number, -): Promise { - const nowDate = new Date(now); - const cutoff = new Date(nowDate); - cutoff.setMonth(cutoff.getMonth() - LABS_LOOKBACK_MONTHS); - - const rows = await prisma.labResult.findMany({ - where: { userId, deletedAt: null, takenAt: { gte: cutoff, lte: nowDate } }, - orderBy: { takenAt: "desc" }, - take: LABS_MAX_FLAGGED * 16, - select: { - analyte: true, - panel: true, - unit: true, - value: true, - valueText: true, - referenceLow: true, - referenceHigh: true, - takenAt: true, - biomarkerId: true, - biomarker: { - select: { - id: true, - name: true, - unit: true, - lowerBound: true, - upperBound: true, - panel: true, - hidden: true, - }, - }, - }, - }); - if (rows.length === 0) return undefined; - - // Group rows per biomarker identity (linked id, else lower-cased analyte), - // newest-first, so we can read the latest reading + the immediately prior one - // for a trend. Hidden markers are dropped entirely. - const byMarker = new Map(); - for (const row of rows) { - if (row.biomarker?.hidden) continue; - const resolved = resolveLabFields(row, row.biomarker); - const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`; - const list = byMarker.get(key) ?? []; - list.push(row); - byMarker.set(key, list); - } - - const flagged: NonNullable["flagged"] = []; - for (const list of byMarker.values()) { - const latest = list[0]; - const resolved = resolveLabFields(latest, latest.biomarker); - const rangeStatus = - latest.value === null - ? ("unknown" as const) - : classifyReferenceRange( - latest.value, - resolved.referenceLow, - resolved.referenceHigh, - ); - - // Trend = latest numeric reading vs the immediately prior numeric reading. - let trend: "rising" | "falling" | "flat" | null = null; - if (latest.value !== null) { - const prior = list.find((r, i) => i > 0 && r.value !== null); - if (prior?.value != null) { - const delta = latest.value - prior.value; - const eps = Math.max(Math.abs(prior.value) * 0.02, 1e-9); - trend = delta > eps ? "rising" : delta < -eps ? "falling" : "flat"; - } - } - - const isAbnormal = rangeStatus === "below" || rangeStatus === "above"; - const isTrending = trend === "rising" || trend === "falling"; - if (!isAbnormal && !isTrending) continue; - - flagged.push({ - analyte: resolved.analyte, - value: latest.value, - valueText: latest.valueText ? sanitizeLabel(latest.valueText, 60) : null, - unit: resolved.unit, - rangeStatus, - trend, - takenAt: latest.takenAt.toISOString(), - daysAgo: Math.round((now - latest.takenAt.getTime()) / MS_PER_DAY), - }); - } - - if (flagged.length === 0) return undefined; - // Abnormal markers lead, then most-recent first. - flagged.sort((a, b) => { - const abn = (s: typeof a.rangeStatus) => - s === "below" || s === "above" ? 0 : 1; - const d = abn(a.rangeStatus) - abn(b.rangeStatus); - return d !== 0 ? d : a.daysAgo - b.daysAgo; - }); - return { - flagged: flagged.slice(0, LABS_MAX_FLAGGED), - flaggedCount: flagged.length, - }; -} - -/** - * v1.22 — preventive-care (Vorsorge) due + overdue read-side. Reads the - * user's enabled, live reminders and buckets by the server-authoritative - * `nextDueAt`. Returns `undefined` when nothing is due or overdue. - */ -async function readPreventiveCareBlock( - userId: string, - now: number, -): Promise { - const horizon = new Date(now + PREVENTIVE_DUE_HORIZON_DAYS * MS_PER_DAY); - const rows = await prisma.measurementReminder.findMany({ - where: { - userId, - deletedAt: null, - enabled: true, - nextDueAt: { not: null, lte: horizon }, - }, - orderBy: { nextDueAt: "asc" }, - take: (PREVENTIVE_MAX_PER_BUCKET + 1) * 4, - select: { label: true, nextDueAt: true }, - }); - if (rows.length === 0) return undefined; - - const overdue: NonNullable["overdue"] = - []; - const due: NonNullable["due"] = []; - for (const r of rows) { - if (!r.nextDueAt) continue; - const label = sanitizeLabel(r.label); - if (!label) continue; - const diffMs = r.nextDueAt.getTime() - now; - if (diffMs < 0) { - overdue.push({ label, daysOverdue: Math.round(-diffMs / MS_PER_DAY) }); - } else { - due.push({ label, daysUntil: Math.round(diffMs / MS_PER_DAY) }); - } - } - if (overdue.length === 0 && due.length === 0) return undefined; - return { - overdue: overdue.slice(0, PREVENTIVE_MAX_PER_BUCKET), - due: due.slice(0, PREVENTIVE_MAX_PER_BUCKET), - }; -} - -/** - * v1.22 — workout aggregate over the trailing window. Provider-agnostic: - * counts + summed duration + summed distance (km, when any source reported it) - * over 7 / 30 days, plus the latest workout. Returns `undefined` when no - * workouts fall in the window. - */ -async function readWorkoutsBlock( - userId: string, - now: number, -): Promise { - const since = new Date(now - WORKOUT_WINDOW_DAYS * MS_PER_DAY); - const rows = await prisma.workout.findMany({ - where: { userId, startedAt: { gte: since } }, - orderBy: { startedAt: "desc" }, - take: 2000, - select: { - sportType: true, - startedAt: true, - durationSec: true, - totalDistanceM: true, - }, - }); - if (rows.length === 0) return undefined; - - const tally = (windowDays: number) => { - const cutoff = now - windowDays * MS_PER_DAY; - let count = 0; - let durationSec = 0; - let distanceM = 0; - let anyDistance = false; - for (const w of rows) { - if (w.startedAt.getTime() < cutoff) continue; - count += 1; - durationSec += w.durationSec; - if (w.totalDistanceM != null) { - distanceM += w.totalDistanceM; - anyDistance = true; - } - } - return { - count, - totalDurationMin: Math.round(durationSec / 60), - totalDistanceKm: anyDistance - ? Math.round((distanceM / 1000) * 10) / 10 - : null, - }; - }; - - const newest = rows[0]; - const latest = { - sportType: sanitizeLabel(newest.sportType, 40), - daysAgo: Math.round((now - newest.startedAt.getTime()) / MS_PER_DAY), - durationMin: Math.round(newest.durationSec / 60), - distanceKm: - newest.totalDistanceM != null - ? Math.round((newest.totalDistanceM / 1000) * 10) / 10 - : null, - }; - - return { last7: tally(7), last30: tally(30), latest }; -} - export async function extractFeatures( userId: string, includeRaw: boolean, @@ -1886,43 +1347,6 @@ export async function extractFeatures( return features; } -/** - * Read every BUCKETED_TYPES × BUCKET_WINDOWS combination from the - * persistent rollup table and project to the wire shape. Empty - * (type, granularity) combinations are dropped so the payload never - * carries a labelled-but-empty series. - */ -async function readBucketedSeries( - userId: string, - now: number, -): Promise { - const series: BucketedSeries[] = []; - for (const type of BUCKETED_TYPES) { - for (const window of BUCKET_WINDOWS) { - const from = new Date(now - window.toDays * 24 * 60 * 60 * 1000); - const to = new Date(now - window.fromDays * 24 * 60 * 60 * 1000); - const rows = await readRollupBuckets( - userId, - type, - window.granularity, - from, - to, - ); - if (rows.length === 0) continue; - series.push({ - type, - granularity: window.granularity, - buckets: rows.map((r) => ({ - bucketStart: r.bucketStart.toISOString(), - mean: Math.round(r.mean * 100) / 100, - count: r.count, - })), - }); - } - } - return series; -} - /** * Hard ceiling on the serialised payload. Throws * `FeaturesPayloadTooLargeError` (tagged with `code` so the route diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index 5ae7ced90..263f4a575 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -11,6 +11,11 @@ * read + orchestration lives in `fetchMoodAggregates` at the bottom; * the per-dimension helpers above it take plain arrays so they unit-test * without a database. + * + * v1.28.26 — the per-dimension calculators live in sibling modules + * (`mood-tag-influence`, `mood-crosstab`, `mood-better-days`, + * `mood-patterns`); this hub re-exports them all, so call sites keep + * importing from here unchanged. */ import { prisma } from "@/lib/db"; @@ -25,23 +30,56 @@ import { type DailyBucket, } from "@/lib/insights/bucket-series"; import { round, summarizeSeries } from "@/lib/insights/status-shared"; -import { wallClockInTz } from "@/lib/tz/wall-clock"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { computeMoodNarratives, type MoodNarrative, } from "@/lib/insights/mood-narratives"; -import { welchTTest } from "@/lib/insights/correlations"; -import { benjaminiHochberg as fdrAdjust } from "@/lib/insights/correlation-discovery"; -import { resolveUserTimezone, toBerlinYmd } from "@/lib/tz/resolver"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { annotate } from "@/lib/logging/context"; -import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { createCustomLabelResolver } from "@/lib/mood/custom-tags"; -import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { + computeBetterDays, + type BetterDayFactor, +} from "@/lib/insights/mood-better-days"; +import { + computeFactorMetricCrosstab, + computeTagMetricCrosstab, + CROSSTAB_METRIC_TYPES, + FACTOR_CROSSTAB_METRIC_TYPES, + type FactorMetricCrosstabRow, + type TagMetricCrosstabRow, +} from "@/lib/insights/mood-crosstab"; +import { + computeDistribution, + computeInTargetPct, + computeMoodStability, + computeTimeOfDayAverages, + computeWeekdayAverages, + type DistributionRow, + type MoodStability, + type TimeOfDayPattern, + type WeekdayRow, +} from "@/lib/insights/mood-patterns"; +import { + computeStructuredTagSummary, + computeTagInfluence, + computeTagSummary, + type StructuredTagRow, + type TagInfluence, + type TagSummaryRow, +} from "@/lib/insights/mood-tag-influence"; import type { MeasurementType } from "@/generated/prisma/enums"; import type { MeasurementSource } from "@/generated/prisma/client"; +// Per-domain mood-analytics modules, re-exported so every existing call +// site keeps importing from this hub. +export * from "@/lib/insights/mood-better-days"; +export * from "@/lib/insights/mood-crosstab"; +export * from "@/lib/insights/mood-patterns"; +export * from "@/lib/insights/mood-tag-influence"; + const MS_PER_DAY = 24 * 60 * 60 * 1000; /** @@ -68,7 +106,7 @@ function parseTags(tags: string | null): string[] { * touch `dayOffset` + `value`, so accepting the narrow shape keeps them * trivially testable without synthesising the unused field. */ -type DailyPoint = Pick; +export type DailyPoint = Pick; /** * A structured tag attached to a mood entry, resolved from the catalog @@ -241,1503 +279,6 @@ export function pairDailyBuckets( ); } -// --- Tag frequency + per-tag avg score (lifted from mood-status) --- - -export interface TagSummaryRow { - tag: string; - count: number; - avgScore: number; -} - -/** - * Tag frequency + per-tag average mood over a recency window. - * - * Keeps the v1.4.5 ~90-day window so the model still gets a recency- - * weighted view of tag patterns; the caller slices by date, not by - * record count. Only tags with count >= 2 survive (a single mention is - * noise), top 10 by frequency. - */ -export function computeTagSummary( - entries: MoodAggregateEntry[], - now: Date, - windowDays = 90, -): TagSummaryRow[] { - const cutoff = now.getTime() - windowDays * MS_PER_DAY; - const tagCounts = new Map(); - for (const entry of entries) { - if (entry.moodLoggedAt.getTime() < cutoff) continue; - if (entry.tags && Array.isArray(entry.tags)) { - for (const tag of entry.tags as string[]) { - const current = tagCounts.get(tag) ?? { count: 0, scoreSum: 0 }; - current.count += 1; - current.scoreSum += entry.score; - tagCounts.set(tag, current); - } - } - } - return Array.from(tagCounts.entries()) - .filter(([, stats]) => stats.count >= 2) - .sort((a, b) => b[1].count - a[1].count) - .slice(0, 10) - .map(([tag, stats]) => ({ - tag, - count: stats.count, - avgScore: round(stats.scoreSum / stats.count, 2), - })); -} - -// --- Structured-tag frequency + per-tag avg score (v1.8.5 taxonomy) --- - -export interface StructuredTagRow { - key: string; - categoryKey: string; - labelKey: string; - /** Decrypted custom-tag label; null for catalogue tags (see `StructuredTagRef`). */ - label?: string | null; - icon: string | null; - count: number; - avgScore: number; -} - -/** - * Frequency + per-tag average mood for the structured taxonomy tags, - * over the same recency window as the flat-tag summary. - * - * Unlike `computeTagSummary` (flat free text), singletons are KEPT — a - * structured tag comes from a curated catalog the user deliberately - * picked, so a single occurrence is signal, not noise. Ranked by - * frequency desc, then label key for a stable order. The category key - * rides along so the UI can group the bars by category with icons. - */ -export function computeStructuredTagSummary( - entries: MoodAggregateEntry[], - now: Date, - windowDays = 90, -): StructuredTagRow[] { - const cutoff = now.getTime() - windowDays * MS_PER_DAY; - const byKey = new Map< - string, - { - categoryKey: string; - labelKey: string; - label: string | null; - icon: string | null; - count: number; - scoreSum: number; - } - >(); - for (const entry of entries) { - if (entry.moodLoggedAt.getTime() < cutoff) continue; - if (!entry.structuredTags) continue; - for (const tag of entry.structuredTags) { - const current = byKey.get(tag.key) ?? { - categoryKey: tag.categoryKey, - labelKey: tag.labelKey, - label: tag.label ?? null, - icon: tag.icon, - count: 0, - scoreSum: 0, - }; - current.count += 1; - current.scoreSum += entry.score; - byKey.set(tag.key, current); - } - } - return Array.from(byKey.entries()) - .map(([key, stats]) => ({ - key, - categoryKey: stats.categoryKey, - labelKey: stats.labelKey, - label: stats.label, - icon: stats.icon, - count: stats.count, - avgScore: round(stats.scoreSum / stats.count, 2), - })) - .sort((a, b) => b.count - a.count || a.labelKey.localeCompare(b.labelKey)); -} - -// --- Tag "Influence on Mood" — with-vs-without daily-mean delta (F1) --- - -/** - * Minimum days a tag must be PRESENT on, and minimum days it must be - * ABSENT on, before its with/without influence surfaces. Both floors - * apply: a tag seen on 4 days has no defensible "with" average, and a - * tag present on all-but-2 days has no defensible "without" baseline. - * Five days per side is the same order as the stability floor (7 daily - * points) but per-group — enough for a mean of daily means to mean - * something without surfacing a two-day fluke as a "factor". - */ -export const INFLUENCE_MIN_PRESENT_DAYS = 5; -export const INFLUENCE_MIN_ABSENT_DAYS = 5; - -/** - * Maximum influence rows surfaced per axis (flat / structured). Ranked by - * absolute delta so the strongest associations lead; the cap keeps the - * multiple-comparison surface bounded (the confidence band downgrades the - * rest honestly). - */ -export const INFLUENCE_MAX_ROWS = 8; - -/** Discrete confidence the UI chip renders for an influence row. */ -export type InfluenceConfidence = "low" | "medium" | "high"; - -export interface TagInfluenceRow { - /** Stable tag key. For flat tags this is the free-text string. */ - tag: string; - /** - * For structured tags, the i18n label key + parent category so the UI - * can render the localized label with its icon. `null` for flat tags - * (rendered verbatim). - */ - labelKey: string | null; - /** Decrypted custom-tag label; null for catalogue / flat tags. */ - label?: string | null; - categoryKey: string | null; - icon: string | null; - /** Days the tag was present (daily-mean convention). */ - withDays: number; - /** Days the tag was absent over the same window. */ - withoutDays: number; - /** Mean of daily means on days the tag was present. */ - withAvg: number; - /** Mean of daily means on days the tag was absent (the counterfactual). */ - withoutAvg: number; - /** withAvg − withoutAvg, rounded. Positive = higher mood with the tag. */ - delta: number; - /** - * Pooled mood SD across the with/without groups (Cohen's-d denominator). - * `null` when neither group has testable spread (both constant). Lets the - * board standardize the raw delta into a unitless effect comparable to a - * Pearson |r|. Not surfaced in the UI — ranking only. - */ - pooledSd: number | null; - /** Welch two-sided p-value for the difference of means. */ - pValue: number; - /** Discrete confidence band derived from p-value + per-group sample size. */ - confidence: InfluenceConfidence; -} - -/** - * Map a Welch p-value + the smaller per-group day count to a discrete - * confidence band. Both inputs matter: a small p on tiny groups is still - * fragile, and a comfortable sample with a borderline p is only suggestive. - * - * - high : p < 0.01 AND both groups ≥ 12 days - * - medium : p < 0.05 AND both groups ≥ 8 days - * - low : everything else that cleared the surfacing floors - * - * The deterministic "no_variance" case (both groups perfectly constant but - * with a non-zero delta) is treated as `low` — a real but un-tested - * difference should never read as confident. - */ -export function influenceConfidence( - pValue: number, - minGroupDays: number, -): InfluenceConfidence { - if (pValue < 0.01 && minGroupDays >= 12) return "high"; - if (pValue < 0.05 && minGroupDays >= 8) return "medium"; - return "low"; -} - -/** - * Collapse raw mood rows to one observation per tz-anchored day: the - * day's mean score plus the union of every flat + structured tag key - * present on any entry that day. Daily-mean convention so a multi-entry - * day never over-weights an influence comparison (it matches the heatmap, - * distribution, and weekday axes). - */ -interface TaggedDay { - /** Day's mean mood (mean of the day's entry scores). */ - mean: number; - /** Flat free-text tag keys present that day. */ - flatTags: Set; - /** Structured tag refs present that day, keyed for de-dup. */ - structuredTags: Map; -} - -function collapseToTaggedDays( - entries: MoodAggregateEntry[], - now: Date, - windowDays: number, -): Map { - const cutoff = now.getTime() - windowDays * MS_PER_DAY; - const byDay = new Map< - string, - { - sum: number; - count: number; - flat: Set; - structured: Map; - } - >(); - for (const entry of entries) { - if (entry.moodLoggedAt.getTime() < cutoff) continue; - const day = byDay.get(entry.date) ?? { - sum: 0, - count: 0, - flat: new Set(), - structured: new Map(), - }; - day.sum += entry.score; - day.count += 1; - if (entry.tags && Array.isArray(entry.tags)) { - for (const tag of entry.tags as string[]) day.flat.add(tag); - } - if (entry.structuredTags) { - for (const ref of entry.structuredTags) day.structured.set(ref.key, ref); - } - byDay.set(entry.date, day); - } - const out = new Map(); - for (const [date, agg] of byDay) { - out.set(date, { - mean: agg.sum / agg.count, - flatTags: agg.flat, - structuredTags: agg.structured, - }); - } - return out; -} - -/** - * Compute the with-vs-without influence row for ONE candidate tag over a - * pre-collapsed day map. `presentOn(day)` decides membership. Returns null - * when either group is below its day floor (so the caller drops the tag) - * or when Welch reports no testable spread AND the means are equal. - */ -function influenceForTag( - days: TaggedDay[], - presentOn: (day: TaggedDay) => boolean, - meta: { - tag: string; - labelKey: string | null; - label: string | null; - categoryKey: string | null; - icon: string | null; - }, -): TagInfluenceRow | null { - const withVals: number[] = []; - const withoutVals: number[] = []; - for (const day of days) { - if (presentOn(day)) withVals.push(day.mean); - else withoutVals.push(day.mean); - } - if ( - withVals.length < INFLUENCE_MIN_PRESENT_DAYS || - withoutVals.length < INFLUENCE_MIN_ABSENT_DAYS - ) { - return null; - } - - const welch = welchTTest(withVals, withoutVals); - - // Deterministic means even when Welch declines (no_variance) — the - // delta is still meaningful, we just cannot attach a p-value, so the - // band falls to `low`. - const withAvg = withVals.reduce((s, v) => s + v, 0) / withVals.length; - const withoutAvg = - withoutVals.reduce((s, v) => s + v, 0) / withoutVals.length; - const delta = withAvg - withoutAvg; - const minGroupDays = Math.min(withVals.length, withoutVals.length); - - // Pooled SD (Cohen's-d denominator) so the board can standardize this raw - // mood-point delta into a unitless effect comparable to a Pearson |r|. - // Unbiased (n−1) per-group variances pooled on (nWith + nWithout − 2) df; - // null when there is no testable spread (both groups perfectly constant). - const nWith = withVals.length; - const nWithout = withoutVals.length; - const varWith = - withVals.reduce((s, v) => s + (v - withAvg) ** 2, 0) / (nWith - 1); - const varWithout = - withoutVals.reduce((s, v) => s + (v - withoutAvg) ** 2, 0) / (nWithout - 1); - const pooledVar = - ((nWith - 1) * varWith + (nWithout - 1) * varWithout) / - (nWith + nWithout - 2); - const pooledSd = pooledVar > 0 ? Math.sqrt(pooledVar) : null; - - const pValue = welch.status === "ok" ? welch.pValue : 1; - // A zero delta with no spread is not an "influence" — drop it so the - // board never shows a 0.0 row. - if (delta === 0) return null; - - return { - tag: meta.tag, - labelKey: meta.labelKey, - label: meta.label, - categoryKey: meta.categoryKey, - icon: meta.icon, - withDays: withVals.length, - withoutDays: withoutVals.length, - withAvg: round(withAvg, 2), - withoutAvg: round(withoutAvg, 2), - delta: round(delta, 2), - pooledSd: pooledSd === null ? null : round(pooledSd, 3), - pValue, - confidence: influenceConfidence(pValue, minGroupDays), - }; -} - -export interface TagInfluence { - /** Flat free-text tag influence rows, ranked by |delta| desc. */ - flat: TagInfluenceRow[]; - /** Structured taxonomy tag influence rows, ranked by |delta| desc. */ - structured: TagInfluenceRow[]; -} - -/** - * Tag "Influence on Mood" (Daylio's flagship): for each frequent flat and - * structured tag, the average daily mood on days the tag is PRESENT vs the - * counterfactual baseline of days it is ABSENT, the delta, and a confidence - * band from a Welch two-sample t-test. - * - * Gating: both groups must clear `INFLUENCE_MIN_PRESENT_DAYS` / - * `INFLUENCE_MIN_ABSENT_DAYS`; rows are ranked by absolute delta and capped - * at `INFLUENCE_MAX_ROWS` per axis. Observational only — the UI applies the - * standing "association, not cause" caption. Sparse tags, single-day tags, - * all-same-value days, and divide-by-zero are all handled cleanly (the row - * is dropped, never NaN). - */ -export function computeTagInfluence( - entries: MoodAggregateEntry[], - now: Date, - windowDays = 90, -): TagInfluence { - const dayMap = collapseToTaggedDays(entries, now, windowDays); - const days = Array.from(dayMap.values()); - - // Need at least one day per side to even attempt — the per-tag floor - // catches the real gate, this just short-circuits an empty history. - if (days.length < INFLUENCE_MIN_PRESENT_DAYS + INFLUENCE_MIN_ABSENT_DAYS) { - return { flat: [], structured: [] }; - } - - // Candidate flat tags: every distinct flat key seen in the window. - const flatKeys = new Set(); - for (const day of days) for (const k of day.flatTags) flatKeys.add(k); - - const flat: TagInfluenceRow[] = []; - for (const key of flatKeys) { - const row = influenceForTag(days, (d) => d.flatTags.has(key), { - tag: key, - labelKey: null, - label: null, - categoryKey: null, - icon: null, - }); - if (row) flat.push(row); - } - - // Candidate structured tags: every distinct structured key + its meta. - const structuredMeta = new Map(); - for (const day of days) { - for (const [key, ref] of day.structuredTags) { - if (!structuredMeta.has(key)) structuredMeta.set(key, ref); - } - } - - const structured: TagInfluenceRow[] = []; - for (const [key, ref] of structuredMeta) { - const row = influenceForTag(days, (d) => d.structuredTags.has(key), { - tag: key, - labelKey: ref.labelKey, - label: ref.label ?? null, - categoryKey: ref.categoryKey, - icon: ref.icon, - }); - if (row) structured.push(row); - } - - const rank = (a: TagInfluenceRow, b: TagInfluenceRow) => - Math.abs(b.delta) - Math.abs(a.delta) || a.tag.localeCompare(b.tag); - - return { - flat: flat.sort(rank).slice(0, INFLUENCE_MAX_ROWS), - structured: structured.sort(rank).slice(0, INFLUENCE_MAX_ROWS), - }; -} - -// --- "What's associated with your better days" board (F2) --- - -/** - * One ranked factor on the unified board. Either a tag (`source: "tag"`, - * effect = the mood-point delta) or a health-metric correlation - * (`source: "metric"`, effect = Pearson r). Direction tells the UI whether - * the factor goes with higher or lower mood; the standing "association, not - * cause" caption is rendered once for the whole board. - */ -export interface BetterDayFactor { - source: "tag" | "metric"; - /** Tag key OR correlation channel key (sleep/steps/pulse/weight/bp). */ - key: string; - /** Flat tags / metrics: null. Structured tags: the i18n label key. */ - labelKey: string | null; - /** Decrypted custom-tag label; null for catalogue / flat / metric rows. */ - label?: string | null; - categoryKey: string | null; - icon: string | null; - /** "up" = associated with higher mood; "down" = with lower mood. */ - direction: "up" | "down"; - /** Sample count behind the factor (tag: smaller group; metric: paired n). */ - n: number; - /** Discrete confidence band. */ - confidence: InfluenceConfidence; - /** - * Unified ranking strength in [0,1]. For metrics this is |r|; for tags it - * is min(1, |delta| / 2) — a two-point mood swing reads as full strength. - * Ranking only; the UI surfaces the raw delta / r, not this number. - */ - effectSize: number; - /** Raw mood-point delta for a tag factor; null for a metric factor. */ - delta: number | null; - /** Raw Pearson r for a metric factor; null for a tag factor. */ - r: number | null; -} - -/** Max factors on the board so the headline surface stays scannable. */ -export const BETTER_DAYS_MAX_FACTORS = 8; - -/** Map a metric correlation strength label to the shared confidence band. */ -function metricConfidence( - strength: CorrelationResult["strength"], -): InfluenceConfidence { - if (strength === "stark") return "high"; - if (strength === "moderat") return "medium"; - return "low"; -} - -/** - * Merge the F1 tag influence rows and the mood × health-metric - * correlations into one effect-size-ranked, confidence-gated board. - * - * Inclusion gates (multiple-comparison aware): - * - Tag rows: already gated by `computeTagInfluence` (sample floors + - * Welch); included as-is. - * - Metric rows: only correlations that reached `n ≥ 5` AND carry a - * non-"keine" strength (|r| ≥ 0.2) are folded in — a near-zero r is not - * an association. - * - * Ranking is by `effectSize` desc, then confidence, then key for stability. - * The two sources are put on one comparable scale: metric rows use |r| - * (already unitless in [0,1]); tag rows use a standardized effect — the raw - * mood-point `delta` divided by the pooled with/without mood SD (Cohen's d), - * clamped to [0,1]. A |d| ≥ 1 (a mean shift of one full SD) saturates the - * scale, mirroring an |r| near its ceiling, so neither source dominates the - * board for scale reasons alone. When a tag has no pooled SD (both groups - * perfectly constant), it falls back to the legacy |delta|/2 heuristic — a - * rare degenerate case that can't be standardized. The raw delta / r the UI - * shows is unchanged; this only governs the sort order. Observational only. - */ -export function computeBetterDays( - tagInfluence: TagInfluence, - correlations: Record, -): BetterDayFactor[] { - const factors: BetterDayFactor[] = []; - - // Tag factors (both axes). Structured tags carry their label meta. - for (const row of [...tagInfluence.structured, ...tagInfluence.flat]) { - factors.push({ - source: "tag", - key: row.tag, - labelKey: row.labelKey, - label: row.label ?? null, - categoryKey: row.categoryKey, - icon: row.icon, - direction: row.delta >= 0 ? "up" : "down", - n: Math.min(row.withDays, row.withoutDays), - confidence: row.confidence, - // Cohen's-d standardization so the tag effect is commensurable with a - // metric |r|; fall back to the legacy |delta|/2 only when the pooled - // SD is unavailable (both groups perfectly constant). - effectSize: - row.pooledSd && row.pooledSd > 0 - ? Math.min(1, Math.abs(row.delta) / row.pooledSd) - : Math.min(1, Math.abs(row.delta) / 2), - delta: row.delta, - r: null, - }); - } - - // Metric factors — only meaningful, sufficiently-sampled correlations. - for (const [key, corr] of Object.entries(correlations)) { - const result = corr.result; - if (!result || corr.n < 5) continue; - if (result.strength === "keine") continue; - factors.push({ - source: "metric", - key, - labelKey: null, - label: null, - categoryKey: null, - icon: null, - // Mood is the x-axis: positive r = higher metric on higher-mood days, - // i.e. the metric is associated with higher mood. - direction: result.r >= 0 ? "up" : "down", - n: corr.n, - confidence: metricConfidence(result.strength), - effectSize: Math.min(1, Math.abs(result.r)), - delta: null, - r: result.r, - }); - } - - const confidenceRank: Record = { - high: 3, - medium: 2, - low: 1, - }; - - return factors - .sort( - (a, b) => - b.effectSize - a.effectSize || - confidenceRank[b.confidence] - confidenceRank[a.confidence] || - a.key.localeCompare(b.key), - ) - .slice(0, BETTER_DAYS_MAX_FACTORS); -} - -// --- Tag × health-metric crosstab (v1.12.0) ------------------------------ - -/** - * v1.12.0 — per-tag × health-metric crosstab. - * - * Daylio's "Activities & Mood" board answers "how does my mood differ on - * days I tagged X?". This extends the same with-vs-without comparison to a - * health METRIC: for each structured mood tag present on enough days, the - * metric's mean on tag-present days vs tag-absent days, the delta, the - * Welch two-sided p, and a confidence band — reusing the EXACT statistics - * engine the F1 tag-influence board already runs (`welchTTest` + - * `influenceConfidence` + the per-group day floors). FDR-corrected across - * every tested (tag × metric) pair via the shared `benjaminiHochberg` - * step-up so the surface stays honest as the matrix grows. - * - * Pairing modes: - * - "sameDay" — the metric on the SAME day the tag was logged. Used for - * activity (a workout/active tag × ACTIVE_ENERGY_BURNED) and sleep (a - * sleep tag × SLEEP_DURATION). - * - "nextDay" — the metric on the day AFTER the tag (D → D+1 lag join, the - * same lag the correlation-discovery engine uses). Used for an - * alcohol/food tag × next-day recovery/readiness (RECOVERY_SCORE), - * where the plausible direction is "tonight's choice → tomorrow's - * recovery". - * - * Observational only — the UI renders the standing "association, not - * cause" caption once. The metric value is shown in its display unit - * (SLEEP_DURATION minutes → hours; energy kcal; recovery score 0..100). - */ -/** Default Benjamini-Hochberg target FDR for the crosstab family. */ -export const CROSSTAB_FDR_Q = 0.1; - -/** - * Per-side day floors for the crosstab — kept in step with the influence - * floors (`INFLUENCE_MIN_PRESENT_DAYS` / `INFLUENCE_MIN_ABSENT_DAYS`) so a tag - * needs the same defensible present/absent support before a metric delta - * surfaces. Declared independently so the crosstab surface owns its own floor. - */ -export const CROSSTAB_MIN_PRESENT_DAYS = 5; -export const CROSSTAB_MIN_ABSENT_DAYS = 5; - -/** Max rows surfaced across the whole crosstab so the surface stays scannable. */ -export const CROSSTAB_MAX_ROWS = 8; - -type CrosstabMode = "sameDay" | "nextDay"; - -/** - * The metric channels the crosstab pairs each tag against. The `display` - * key drives unit formatting on the client; `mode` fixes the pairing lag. - * Single-sourced so the fetch filter (`CROSSTAB_METRIC_TYPES`) and the - * compute can never drift. - */ -export const CROSSTAB_METRICS: Record< - string, - { - type: MeasurementType; - mode: CrosstabMode; - display: "hours" | "kcal" | "score"; - } -> = { - // A workout / active tag × same-day active energy. - activeEnergy: { - type: "ACTIVE_ENERGY_BURNED", - mode: "sameDay", - display: "kcal", - }, - // A sleep tag × same-night sleep duration (stored minutes → hours on UI). - sleepDuration: { type: "SLEEP_DURATION", mode: "sameDay", display: "hours" }, - // An alcohol / food tag × next-day recovery/readiness (D → D+1 lag). - nextDayRecovery: { - type: "RECOVERY_SCORE", - mode: "nextDay", - display: "score", - }, -} as const; - -export type CrosstabMetricKey = keyof typeof CROSSTAB_METRICS; - -/** Distinct measurement types the crosstab reads — single-sourced. */ -export const CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from( - new Set(Object.values(CROSSTAB_METRICS).map((m) => m.type)), -); - -export interface TagMetricCrosstabRow { - /** Stable structured-tag key. */ - tag: string; - /** i18n label key for the tag (structured tags only — never flat). */ - labelKey: string; - /** Decrypted custom-tag label; null for catalogue tags. */ - label?: string | null; - /** Parent category key, for grouping/icon. */ - categoryKey: string; - /** Lucide icon name, or null. */ - icon: string | null; - /** Which metric channel this row compares against. */ - metricKey: CrosstabMetricKey; - /** Display unit hint for the client formatter. */ - display: "hours" | "kcal" | "score"; - /** Pairing mode used (echoed so the UI can caption "next-day"). */ - mode: CrosstabMode; - /** Days the tag was present that had a paired metric value. */ - withDays: number; - /** Days the tag was absent that had a paired metric value. */ - withoutDays: number; - /** Mean metric on tag-present days (display unit). */ - withAvg: number; - /** Mean metric on tag-absent days (display unit). */ - withoutAvg: number; - /** withAvg − withoutAvg (display unit). */ - delta: number; - /** Welch two-sided p-value for the difference of means. */ - pValue: number; - /** Benjamini-Hochberg adjusted q-value across the tested family. */ - qValue: number; - /** Discrete confidence band (p + min per-group day count). */ - confidence: InfluenceConfidence; -} - -const CROSSTAB_SUM_TYPES = new Set([ - "ACTIVE_ENERGY_BURNED", - "SLEEP_DURATION", - "ACTIVITY_STEPS", -]); - -/** Berlin-calendar day key (`YYYY-MM-DD`) for a row's `measuredAt`. */ -function berlinDayKey(measuredAt: Date): string { - const { year, month, day } = toBerlinYmd(measuredAt); - return `${year}-${month}-${day}`; -} - -/** - * Build a Berlin-day-keyed metric map with the right aggregation. Energy - * and step totals are SUMMED per day (HealthKit `stats:` rows are already - * one daily-total row, so the sum is the total either way); sleep duration - * is SUMMED across per-stage rows to get the night total; everything else - * (a once-daily score) is MEANED. Returns minutes/kcal/raw — the display - * conversion happens at row-build time. - * - * Cross-source de-dup: before bucketing, the rows for this metric run - * through the SAME canonical-source picker the analytics steps/sleep path - * uses (`pickCanonicalSourceRows`, keyed by `metricKeyForType` + the - * Berlin day key). Without it, the moment two sources report the same day - * (Fitbit + Apple steps, Fitbit + WHOOP sleep) the SUM channels would - * double-count and bias the Welch delta. The picker collapses each day to - * one source (and one device-type within it), so the sum reflects one - * stream. MEAN channels (a once-daily score like recovery) gain the same - * single-source guarantee for free. Rows without a `source` (the legacy - * test fixtures, or a metric whose source isn't in the ladder) fall - * through the picker's pass-through branch unchanged. - */ -export function metricDayMap( - measurements: CrossMetricMeasurement[], - type: string, - userPriorityJson: unknown, -): Map { - const summed = CROSSTAB_SUM_TYPES.has(type); - - const typeRows = measurements.filter((m) => m.type === type); - if (typeRows.length === 0) return new Map(); - - // Resolve the canonical source per day. `metricKeyForType` maps the - // crosstab's MeasurementType to its priority ladder; a metric with no - // ladder (or rows without a source) keeps every row via the picker's - // documented pass-through fallback, so behaviour is identical to the - // pre-fix sum for single-source data. - const metricKey = metricKeyForType(type as MeasurementType); - const canonicalRows = metricKey - ? pickCanonicalSourceRows( - typeRows.map((m) => ({ - measuredAt: m.measuredAt, - source: (m.source ?? "MANUAL") as MeasurementSource, - deviceType: m.deviceType ?? null, - type: type as MeasurementType, - value: m.value, - })), - metricKey, - userPriorityJson, - berlinDayKey, - ).canonicalRows - : typeRows; - - const byDay = new Map(); - for (const m of canonicalRows) { - const key = berlinDayKey(m.measuredAt); - const cur = byDay.get(key) ?? { sum: 0, count: 0 }; - cur.sum += m.value; - cur.count += 1; - byDay.set(key, cur); - } - const out = new Map(); - for (const [key, agg] of byDay) { - out.set(key, summed ? agg.sum : agg.sum / agg.count); - } - return out; -} - -/** Add `lagDays` to a YYYY-MM-DD day key (UTC-anchored, DST-immune). */ -export function shiftDayKey(day: string, lagDays: number): string { - const [y, m, d] = day.split("-").map(Number); - const dt = new Date(Date.UTC(y, m - 1, d)); - dt.setUTCDate(dt.getUTCDate() + lagDays); - return dt.toISOString().slice(0, 10); -} - -/** Convert a raw metric value to the row's display unit. */ -function toDisplayUnit( - value: number, - display: "hours" | "kcal" | "score", -): number { - return display === "hours" ? value / 60 : value; -} - -interface CrosstabCandidate { - row: Omit; -} - -/** - * Compute the tag × metric crosstab. Pure over already-fetched rows. - * - * For every structured tag and every configured metric channel, collapse - * the window to one observation per tz-anchored day (the tag's daily - * membership) joined to the metric's per-day value (same-day or D+1). The - * tag-present and tag-absent metric samples feed `welchTTest`; rows that - * clear both day floors are tested, FDR-corrected as one family, and the - * survivors (p < 0.05 AND q ≤ `CROSSTAB_FDR_Q`) surface ranked by q then - * |delta|. - * - * Structured tags only — flat free-text tags are excluded because the - * crosstab needs a stable, localized label and a curated-catalog tag is - * the right granularity for a metric comparison. - */ -export function computeTagMetricCrosstab(args: { - entries: MoodAggregateEntry[]; - measurements: CrossMetricMeasurement[]; - now: Date; - windowDays?: number; - /** - * The user's source-priority blob. Threaded into `metricDayMap` so the - * per-day canonical-source pick honours the user's ladder. `null` (the - * test default) resolves to the default ladders. - */ - userPriorityJson?: unknown; -}): TagMetricCrosstabRow[] { - const { entries, measurements, now } = args; - const windowDays = args.windowDays ?? 365; - const userPriorityJson = args.userPriorityJson ?? null; - - const dayMap = collapseToTaggedDays(entries, now, windowDays); - if (dayMap.size === 0) return []; - - // The set of structured tags seen in the window, with their meta. - const structuredMeta = new Map(); - for (const day of dayMap.values()) { - for (const [key, ref] of day.structuredTags) { - if (!structuredMeta.has(key)) structuredMeta.set(key, ref); - } - } - if (structuredMeta.size === 0) return []; - - const candidates: CrosstabCandidate[] = []; - - for (const [metricKey, cfg] of Object.entries(CROSSTAB_METRICS) as Array< - [CrosstabMetricKey, (typeof CROSSTAB_METRICS)[CrosstabMetricKey]] - >) { - const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson); - if (metricByDay.size === 0) continue; - - for (const [tagKey, ref] of structuredMeta) { - const withVals: number[] = []; - const withoutVals: number[] = []; - for (const [dayKey, day] of dayMap) { - const metricKeyDay = - cfg.mode === "nextDay" ? shiftDayKey(dayKey, 1) : dayKey; - const metricValue = metricByDay.get(metricKeyDay); - if (metricValue == null || !Number.isFinite(metricValue)) continue; - const display = toDisplayUnit(metricValue, cfg.display); - if (day.structuredTags.has(tagKey)) withVals.push(display); - else withoutVals.push(display); - } - - if ( - withVals.length < CROSSTAB_MIN_PRESENT_DAYS || - withoutVals.length < CROSSTAB_MIN_ABSENT_DAYS - ) { - continue; - } - - const welch = welchTTest(withVals, withoutVals); - const withAvg = withVals.reduce((s, v) => s + v, 0) / withVals.length; - const withoutAvg = - withoutVals.reduce((s, v) => s + v, 0) / withoutVals.length; - const delta = withAvg - withoutAvg; - if (delta === 0) continue; - - const pValue = welch.status === "ok" ? welch.pValue : 1; - const minGroupDays = Math.min(withVals.length, withoutVals.length); - - candidates.push({ - row: { - tag: tagKey, - labelKey: ref.labelKey, - label: ref.label ?? null, - categoryKey: ref.categoryKey, - icon: ref.icon, - metricKey, - display: cfg.display, - mode: cfg.mode, - withDays: withVals.length, - withoutDays: withoutVals.length, - withAvg: round(withAvg, 2), - withoutAvg: round(withoutAvg, 2), - delta: round(delta, 2), - pValue, - confidence: influenceConfidence(pValue, minGroupDays), - }, - }); - } - } - - if (candidates.length === 0) return []; - - // FDR-correct across the whole tested family (every tag × metric pair) so - // the multiple-comparison surface stays honest as the matrix grows. Reuses - // the same Benjamini-Hochberg step-up the correlation-discovery engine runs. - const qValues = fdrAdjust(candidates.map((c) => c.row.pValue)); - - return candidates - .map((c, i) => ({ ...c.row, qValue: Math.round(qValues[i] * 1000) / 1000 })) - .filter((row) => row.pValue < 0.05 && row.qValue <= CROSSTAB_FDR_Q) - .sort( - (a, b) => - a.qValue - b.qValue || - Math.abs(b.delta) - Math.abs(a.delta) || - a.tag.localeCompare(b.tag), - ) - .slice(0, CROSSTAB_MAX_ROWS); -} - -// --- RATED factor × health-metric crosstab (low- vs high-factor days) --- - -/** - * v1.14.0 — the flagship cross-domain insight: "on days you rated - * low, your ran X below baseline". - * - * A RATED mood factor (work / sleep-quality / stress …) is a CONTINUOUS - * per-day score, unlike the BINARY structured tags `computeTagMetricCrosstab` - * handles. To reuse the EXACT same Welch + FDR engine, this thresholds the - * factor's daily mean into a binary membership — a day is "low" when its - * factor mean sits BELOW the factor's own median over the window, "high" at - * or above. The median split is robust + self-calibrating (always two - * non-empty groups when the factor has spread), and invents no fixed cutoff. - * - * `inverse` is applied at the SERIES boundary (the factor's per-day mean is - * flipped to `(scaleMin + scaleMax) - raw` for an inverse factor like - * stress), so the median split — and therefore the "low day" label — always - * means "a worse day for this factor", honestly, without per-call casing. - * - * Same honesty discipline as the tag crosstab: both sides need - * `CROSSTAB_MIN_PRESENT_DAYS` / `CROSSTAB_MIN_ABSENT_DAYS` paired metric - * days, the family is BH-FDR corrected as one (across every factor × metric - * × direction pair), survivors clear p < 0.05 AND q ≤ `CROSSTAB_FDR_Q`, and - * the surface is capped + ranked. Observational only — the card renders the - * standing "association, not cause" caption. - */ - -/** - * The metric channels the factor crosstab pairs each RATED factor against. - * Broader than the tag crosstab (which is activity-focused) because the - * value of a RATED factor is its bridge from a SUBJECTIVE score to an - * OBJECTIVE vital. `sameDay` for same-night/same-day metrics (sleep, - * steps); `nextDay` (D → D+1) for overnight-recovery metrics (RHR / HRV), - * matching the plausible "today's factor → tomorrow's body" direction. - */ -export const FACTOR_CROSSTAB_METRICS: Record< - string, - { - type: MeasurementType; - mode: CrosstabMode; - display: "hours" | "score" | "steps" | "bpm" | "ms" | "kg" | "mmHg"; - } -> = { - sleepDuration: { type: "SLEEP_DURATION", mode: "sameDay", display: "hours" }, - steps: { type: "ACTIVITY_STEPS", mode: "sameDay", display: "steps" }, - restingHeartRate: { - type: "RESTING_HEART_RATE", - mode: "nextDay", - display: "bpm", - }, - heartRateVariability: { - type: "HEART_RATE_VARIABILITY", - mode: "nextDay", - display: "ms", - }, - weight: { type: "WEIGHT", mode: "sameDay", display: "kg" }, - bloodPressureSystolic: { - type: "BLOOD_PRESSURE_SYS", - mode: "sameDay", - display: "mmHg", - }, -} as const; - -export type FactorCrosstabMetricKey = keyof typeof FACTOR_CROSSTAB_METRICS; - -/** Distinct measurement types the factor crosstab reads — single-sourced. */ -export const FACTOR_CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from( - new Set(Object.values(FACTOR_CROSSTAB_METRICS).map((m) => m.type)), -); - -export interface FactorMetricCrosstabRow { - /** Stable RATED-factor key. */ - factor: string; - /** i18n label key for the factor. */ - labelKey: string; - /** Parent category key, for grouping/icon. */ - categoryKey: string; - /** Lucide icon name, or null. */ - icon: string | null; - /** - * `true` when the factor is inverse-scaled (stress / conflict). The - * UI flips the phrasing — "your worse days" — but the split - * itself already runs on the flipped series, so a "low" row always - * means a worse day regardless. - */ - inverse: boolean; - /** Which metric channel this row compares against. */ - metricKey: FactorCrosstabMetricKey; - /** Display unit hint for the client formatter. */ - display: FactorMetricDisplay; - /** Pairing mode used (echoed so the UI can caption "next-day"). */ - mode: CrosstabMode; - /** Days the factor was rated LOW (below its median) with a paired metric. */ - lowDays: number; - /** Days the factor was rated HIGH (at/above its median) with a paired metric. */ - highDays: number; - /** Mean metric on low-factor days (display unit). */ - lowAvg: number; - /** Mean metric on high-factor days (display unit). */ - highAvg: number; - /** lowAvg − highAvg (display unit). Negative = vital runs lower on low days. */ - delta: number; - /** Welch two-sided p-value for the difference of means. */ - pValue: number; - /** Benjamini-Hochberg adjusted q-value across the tested family. */ - qValue: number; - /** Discrete confidence band (p + min per-group day count). */ - confidence: InfluenceConfidence; -} - -type FactorMetricDisplay = - (typeof FACTOR_CROSSTAB_METRICS)[FactorCrosstabMetricKey]["display"]; - -/** Convert a raw metric value to its factor-crosstab display unit. */ -function toFactorDisplayUnit( - value: number, - display: FactorMetricDisplay, -): number { - return display === "hours" ? value / 60 : value; -} - -/** Median of a numeric array (sorted-copy, mean-of-two for even length). */ -function median(values: number[]): number { - if (values.length === 0) return Number.NaN; - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 === 0 - ? (sorted[mid - 1] + sorted[mid]) / 2 - : sorted[mid]; -} - -/** Reference data for one RATED factor's daily series + its meta. */ -interface FactorDailySeries { - ref: Pick< - RatedFactorScore, - "key" | "labelKey" | "categoryKey" | "icon" | "inverse" - >; - /** Day key → factor's daily-mean score, inverse-flipped if needed. */ - byDay: Map; -} - -/** - * Build a per-factor daily-mean series, applying the `inverse` sign-flip - * once. For each tz-anchored day, the mean of the factor's ratings across - * the day's entries; an inverse factor's rating `r` maps to - * `(scaleMin + scaleMax) - r` BEFORE averaging so "up" always reads as a - * better day. Pure. Exported for the discovery-channel path (period - * narrative) so the two consumers can never diverge on the flip. - */ -export function buildFactorDailySeries( - entries: MoodAggregateEntry[], - now: Date, - windowDays: number, -): Map { - const cutoff = now.getTime() - windowDays * MS_PER_DAY; - const acc = new Map< - string, - { - ref: FactorDailySeries["ref"]; - byDay: Map; - } - >(); - for (const entry of entries) { - if (entry.moodLoggedAt.getTime() < cutoff) continue; - if (!entry.ratedFactors) continue; - for (const f of entry.ratedFactors) { - if (!Number.isFinite(f.rating)) continue; - // The documented sign-flip: an inverse factor's rating is mirrored - // across its scale midpoint so a higher series value always means a - // better day. Done here, once, at the series boundary. - const value = f.inverse ? f.scaleMin + f.scaleMax - f.rating : f.rating; - const slot = acc.get(f.key) ?? { - ref: { - key: f.key, - labelKey: f.labelKey, - categoryKey: f.categoryKey, - icon: f.icon, - inverse: f.inverse, - }, - byDay: new Map(), - }; - const cur = slot.byDay.get(entry.date) ?? { sum: 0, count: 0 }; - cur.sum += value; - cur.count += 1; - slot.byDay.set(entry.date, cur); - acc.set(f.key, slot); - } - } - const out = new Map(); - for (const [key, slot] of acc) { - const byDay = new Map(); - for (const [day, agg] of slot.byDay) byDay.set(day, agg.sum / agg.count); - out.set(key, { ref: slot.ref, byDay }); - } - return out; -} - -interface FactorCrosstabCandidate { - row: Omit; -} - -/** - * Compute the RATED-factor × metric crosstab. Pure over already-fetched - * rows. Mirrors `computeTagMetricCrosstab` but splits each factor's - * continuous daily score into low/high by its own median, then runs the - * same Welch + FDR engine. - */ -export function computeFactorMetricCrosstab(args: { - entries: MoodAggregateEntry[]; - measurements: CrossMetricMeasurement[]; - now: Date; - windowDays?: number; - userPriorityJson?: unknown; -}): FactorMetricCrosstabRow[] { - const { entries, measurements, now } = args; - const windowDays = args.windowDays ?? 365; - const userPriorityJson = args.userPriorityJson ?? null; - - const factorSeries = buildFactorDailySeries(entries, now, windowDays); - if (factorSeries.size === 0) return []; - - const candidates: FactorCrosstabCandidate[] = []; - - for (const [metricKey, cfg] of Object.entries( - FACTOR_CROSSTAB_METRICS, - ) as Array< - [ - FactorCrosstabMetricKey, - (typeof FACTOR_CROSSTAB_METRICS)[FactorCrosstabMetricKey], - ] - >) { - const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson); - if (metricByDay.size === 0) continue; - - for (const [factorKey, series] of factorSeries) { - // The median split is over the factor's own rated days only, so a - // sparse factor never borrows another's threshold. - const ratedDays = [...series.byDay.values()]; - if ( - ratedDays.length < - CROSSTAB_MIN_PRESENT_DAYS + CROSSTAB_MIN_ABSENT_DAYS - ) { - continue; - } - const split = median(ratedDays); - - const lowVals: number[] = []; - const highVals: number[] = []; - for (const [dayKey, score] of series.byDay) { - const metricDayKey = - cfg.mode === "nextDay" ? shiftDayKey(dayKey, 1) : dayKey; - const metricValue = metricByDay.get(metricDayKey); - if (metricValue == null || !Number.isFinite(metricValue)) continue; - const display = toFactorDisplayUnit(metricValue, cfg.display); - // Below the median is a "low" (worse, after the inverse flip) day; - // at or above is "high". A perfectly bimodal factor with all days - // exactly on the median falls entirely into "high" and fails the - // low-side floor below — honest: no contrast, no row. - if (score < split) lowVals.push(display); - else highVals.push(display); - } - - if ( - lowVals.length < CROSSTAB_MIN_PRESENT_DAYS || - highVals.length < CROSSTAB_MIN_ABSENT_DAYS - ) { - continue; - } - - const welch = welchTTest(lowVals, highVals); - const lowAvg = lowVals.reduce((s, v) => s + v, 0) / lowVals.length; - const highAvg = highVals.reduce((s, v) => s + v, 0) / highVals.length; - const delta = lowAvg - highAvg; - if (delta === 0) continue; - - const pValue = welch.status === "ok" ? welch.pValue : 1; - const minGroupDays = Math.min(lowVals.length, highVals.length); - - candidates.push({ - row: { - factor: factorKey, - labelKey: series.ref.labelKey, - categoryKey: series.ref.categoryKey, - icon: series.ref.icon, - inverse: series.ref.inverse, - metricKey, - display: cfg.display, - mode: cfg.mode, - lowDays: lowVals.length, - highDays: highVals.length, - lowAvg: round(lowAvg, 2), - highAvg: round(highAvg, 2), - delta: round(delta, 2), - pValue, - confidence: influenceConfidence(pValue, minGroupDays), - }, - }); - } - } - - if (candidates.length === 0) return []; - - // One BH family across every factor × metric pair tested — the same - // step-up the tag crosstab + discovery engine run. - const qValues = fdrAdjust(candidates.map((c) => c.row.pValue)); - - return candidates - .map((c, i) => ({ ...c.row, qValue: Math.round(qValues[i] * 1000) / 1000 })) - .filter((row) => row.pValue < 0.05 && row.qValue <= CROSSTAB_FDR_Q) - .sort( - (a, b) => - a.qValue - b.qValue || - Math.abs(b.delta) - Math.abs(a.delta) || - a.factor.localeCompare(b.factor), - ) - .slice(0, CROSSTAB_MAX_ROWS); -} - -// --- In-target % over the last 30 daily points (lifted from mood-status) --- - -/** - * Share (0..100) of the newest 30 daily mood buckets that land in the - * green band (>= 3.5). Returns null when there is no recent data. - */ -export function computeInTargetPct(daily: DailyPoint[]): number | null { - const recent = daily.filter((bucket) => bucket.dayOffset < 30); - if (recent.length === 0) return null; - const inTarget = recent.filter( - (entry) => entry.value >= MOOD_GREEN_MIN && entry.value <= MOOD_GREEN_MAX, - ).length; - return round((inTarget / recent.length) * 100, 1); -} - -// --- Mood distribution (share per discrete level) --- - -export interface DistributionRow { - /** Rounded mood level 1..5. */ - score: number; - count: number; -} - -/** - * Distribution of daily-mean mood across the five discrete levels. - * - * Open product question #2 (design §7) resolves to the daily-mean - * convention used everywhere else on the surface: each day contributes - * one observation, its mean rounded to the nearest level. This keeps - * multi-entry days from over-weighting the histogram and matches the - * heatmap (one cell per day). - */ -export function computeDistribution(daily: DailyPoint[]): DistributionRow[] { - const counts = new Map(); - for (const bucket of daily) { - const level = Math.min(5, Math.max(1, Math.round(bucket.value))); - counts.set(level, (counts.get(level) ?? 0) + 1); - } - const rows: DistributionRow[] = []; - for (let score = 1; score <= 5; score++) { - rows.push({ score, count: counts.get(score) ?? 0 }); - } - return rows; -} - -// --- Average mood by weekday --- - -export interface WeekdayRow { - /** 0 = Monday … 6 = Sunday. */ - weekday: number; - avgScore: number | null; - count: number; -} - -/** - * Average daily-mean mood grouped by weekday (Monday = 0). The weekday - * is read off the day-key in UTC so it matches the heatmap's UTC-anchored - * Monday alignment (`compliance-heatmap` reads `getUTCDay`). - */ -export function computeWeekdayAverages( - daily: DailyPoint[], - now: Date, - tz: string = DEFAULT_TIMEZONE, -): WeekdayRow[] { - const sums = new Map(); - for (const bucket of daily) { - const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset, tz); - const d = new Date(dayKey + "T00:00:00Z"); - const weekday = (d.getUTCDay() + 6) % 7; // Monday = 0 - const cur = sums.get(weekday) ?? { sum: 0, count: 0 }; - cur.sum += bucket.value; - cur.count += 1; - sums.set(weekday, cur); - } - const rows: WeekdayRow[] = []; - for (let weekday = 0; weekday < 7; weekday++) { - const agg = sums.get(weekday); - rows.push({ - weekday, - avgScore: agg ? round(agg.sum / agg.count, 2) : null, - count: agg?.count ?? 0, - }); - } - return rows; -} - -// --- Time-of-day pattern (tz-aware part-of-day buckets) --- - -/** Part-of-day bucket key. Order is morning → night for stable rendering. */ -export type TimeOfDayBucket = "morning" | "afternoon" | "evening" | "night"; - -/** Ordered bucket list — drives both the chart x-axis and the iteration. */ -export const TIME_OF_DAY_BUCKETS: readonly TimeOfDayBucket[] = [ - "morning", - "afternoon", - "evening", - "night", -] as const; - -/** - * Minimum entries a single bucket must hold before it counts toward the - * pattern. A lone log in a bucket is noise, not a daypart preference. - */ -export const TIME_OF_DAY_MIN_BUCKET_SAMPLES = 3; - -/** - * Minimum distinct populated buckets (each at or above the per-bucket - * sample floor) before the pattern surfaces at all. The guard against the - * once-a-day logger: a nightly Telegram check-in clusters in a single - * bucket, which can never clear a two-bucket spread, so the "you feel - * best in the morning" takeaway never fires misleadingly. - */ -export const TIME_OF_DAY_MIN_SPREAD = 2; - -/** - * Map a local hour-of-day (0..23) to its part-of-day bucket. - * - * morning 05:00–11:59, afternoon 12:00–16:59, evening 17:00–20:59, - * night 21:00–04:59. The boundaries follow the common Daylio / consumer - * convention; night wraps midnight so a 02:00 log lands in `night`. - */ -export function bucketForHour(hour: number): TimeOfDayBucket { - if (hour >= 5 && hour < 12) return "morning"; - if (hour >= 12 && hour < 17) return "afternoon"; - if (hour >= 17 && hour < 21) return "evening"; - return "night"; -} - -export interface TimeOfDayRow { - bucket: TimeOfDayBucket; - avgScore: number | null; - count: number; -} - -export interface TimeOfDayPattern { - /** All four buckets, in canonical order; unpopulated buckets carry null. */ - buckets: TimeOfDayRow[]; - /** - * Whether the spread + sample floors are cleared — i.e. the pattern is - * trustworthy enough to surface a chart and a takeaway. False for the - * once-a-day logger (everything in one bucket) or a sparse history. - */ - reliable: boolean; - /** Best / worst bucket keys when `reliable`; null otherwise. */ - best: TimeOfDayBucket | null; - worst: TimeOfDayBucket | null; -} - -/** - * Average mood per part of day, bucketed in each entry's own timezone. - * - * `moodLoggedAt` carries the exact instant; `tz` (per-row IANA) anchors - * the local hour. Legacy rows without a `tz` fall back to UTC — the same - * convention the rollup tier uses for `tz IS NULL` mood rows. Every entry - * (not the daily mean) feeds its bucket, because the question is "what - * time of day do I feel best", which a daily collapse would erase. - * - * The `reliable` flag is the once-a-day-logger guard: it only trips when - * at least `TIME_OF_DAY_MIN_SPREAD` buckets each carry - * `TIME_OF_DAY_MIN_BUCKET_SAMPLES` entries. `best`/`worst` are computed - * over the populated-and-sufficient buckets only. - */ -export function computeTimeOfDayAverages( - entries: MoodAggregateEntry[], -): TimeOfDayPattern { - const sums = new Map(); - for (const entry of entries) { - const tz = entry.tz ?? "UTC"; - const { hour } = wallClockInTz(entry.moodLoggedAt, tz); - const bucket = bucketForHour(hour); - const cur = sums.get(bucket) ?? { sum: 0, count: 0 }; - cur.sum += entry.score; - cur.count += 1; - sums.set(bucket, cur); - } - - const buckets: TimeOfDayRow[] = TIME_OF_DAY_BUCKETS.map((bucket) => { - const agg = sums.get(bucket); - return { - bucket, - avgScore: agg ? round(agg.sum / agg.count, 2) : null, - count: agg?.count ?? 0, - }; - }); - - const sufficient = buckets.filter( - (row): row is TimeOfDayRow & { avgScore: number } => - row.avgScore != null && row.count >= TIME_OF_DAY_MIN_BUCKET_SAMPLES, - ); - const reliable = sufficient.length >= TIME_OF_DAY_MIN_SPREAD; - - let best: TimeOfDayBucket | null = null; - let worst: TimeOfDayBucket | null = null; - if (reliable) { - let bestRow = sufficient[0]; - let worstRow = sufficient[0]; - for (const row of sufficient) { - if (row.avgScore > bestRow.avgScore) bestRow = row; - if (row.avgScore < worstRow.avgScore) worstRow = row; - } - best = bestRow.bucket; - worst = worstRow.bucket; - } - - return { buckets, reliable, best, worst }; -} - -// --- Mood stability score (variance of daily means → 0..100) --- - -/** - * Minimum distinct daily points before a stability score is computed. A - * handful of days has no meaningful variance signal, so a sparse logger - * gets `null` (no tile, no sentence) rather than a noisy number. - */ -export const STABILITY_MIN_DAYS = 7; - -/** - * The full-scale standard deviation that maps to a 0 stability score. The - * mood scale spans 1..5, so the widest day-to-day swing is 4 points; a - * population SD at or above this is treated as maximally unstable. Below - * it, the score scales linearly toward 100 (perfectly steady). - */ -export const STABILITY_SD_FULL_SCALE = 1.5; - -export type StabilityBand = - "verySteady" | "steady" | "variable" | "veryVariable"; - -export interface MoodStability { - /** 0..100; higher = steadier (lower day-to-day variance). */ - score: number; - /** Population standard deviation of the daily means (raw, for tests). */ - stdDev: number; - /** Descriptive, non-judgemental band. */ - band: StabilityBand; - /** Daily points the score was computed over. */ - days: number; -} - -/** - * Map a 0..100 stability score to a four-band descriptive label. - * Descriptive, not judgemental — some variation is healthy (Oura framing), - * so the bands read "steady" / "variable", never "good" / "bad". - */ -function stabilityBand(score: number): StabilityBand { - if (score >= 80) return "verySteady"; - if (score >= 60) return "steady"; - if (score >= 40) return "variable"; - return "veryVariable"; -} - -/** - * Mood-stability score from the population standard deviation of the - * daily means. - * - * Formula: - * sd = sqrt( mean( (x_i - mean(x))^2 ) ) // population SD - * score = round( 100 * (1 - min(sd, FULL) / FULL) ) // clamped 0..100 - * - * A flat mood (sd = 0) scores 100; an sd at or beyond - * `STABILITY_SD_FULL_SCALE` scores 0. Returns `null` below - * `STABILITY_MIN_DAYS` distinct daily points so a sparse logger never - * gets a meaningless score. - */ -export function computeMoodStability( - daily: DailyPoint[], -): MoodStability | null { - if (daily.length < STABILITY_MIN_DAYS) return null; - - const values = daily.map((bucket) => bucket.value); - const avg = values.reduce((sum, v) => sum + v, 0) / values.length; - const variance = - values.reduce((sum, v) => sum + (v - avg) ** 2, 0) / values.length; - const stdDev = Math.sqrt(variance); - - const normalised = - Math.min(stdDev, STABILITY_SD_FULL_SCALE) / STABILITY_SD_FULL_SCALE; - const score = Math.round(100 * (1 - normalised)); - - return { - score, - stdDev: round(stdDev, 3), - band: stabilityBand(score), - days: daily.length, - }; -} - // --- Heatmap (one cell per day, score band per cell) --- export type HeatmapWindowDays = 30 | 90 | 365; @@ -1847,7 +388,7 @@ export const CORRELATION_METRICS = { bloodPressureSystolic: "BLOOD_PRESSURE_SYS", } as const; -type CorrelationKey = keyof typeof CORRELATION_METRICS; +export type CorrelationKey = keyof typeof CORRELATION_METRICS; export interface MoodAggregates { summary: { diff --git a/src/lib/insights/mood-better-days.ts b/src/lib/insights/mood-better-days.ts new file mode 100644 index 000000000..31b0570c5 --- /dev/null +++ b/src/lib/insights/mood-better-days.ts @@ -0,0 +1,160 @@ +/** + * The unified "what's associated with your better days" board (F2): + * folds the F1 tag-influence rows and the mood × health-metric + * correlations into one effect-size-ranked, confidence-gated, + * observational list. + * + * Extracted verbatim from `mood-aggregates.ts`, which re-exports this + * module so every existing call site keeps importing from the hub. + * Pure functions over already-computed aggregate shapes. + */ + +import type { CorrelationResult } from "@/lib/analytics/correlations"; +import type { + CorrelationKey, + MoodMetricCorrelation, +} from "@/lib/insights/mood-aggregates"; +import type { + InfluenceConfidence, + TagInfluence, +} from "@/lib/insights/mood-tag-influence"; + +// --- "What's associated with your better days" board (F2) --- + +/** + * One ranked factor on the unified board. Either a tag (`source: "tag"`, + * effect = the mood-point delta) or a health-metric correlation + * (`source: "metric"`, effect = Pearson r). Direction tells the UI whether + * the factor goes with higher or lower mood; the standing "association, not + * cause" caption is rendered once for the whole board. + */ +export interface BetterDayFactor { + source: "tag" | "metric"; + /** Tag key OR correlation channel key (sleep/steps/pulse/weight/bp). */ + key: string; + /** Flat tags / metrics: null. Structured tags: the i18n label key. */ + labelKey: string | null; + /** Decrypted custom-tag label; null for catalogue / flat / metric rows. */ + label?: string | null; + categoryKey: string | null; + icon: string | null; + /** "up" = associated with higher mood; "down" = with lower mood. */ + direction: "up" | "down"; + /** Sample count behind the factor (tag: smaller group; metric: paired n). */ + n: number; + /** Discrete confidence band. */ + confidence: InfluenceConfidence; + /** + * Unified ranking strength in [0,1]. For metrics this is |r|; for tags it + * is min(1, |delta| / 2) — a two-point mood swing reads as full strength. + * Ranking only; the UI surfaces the raw delta / r, not this number. + */ + effectSize: number; + /** Raw mood-point delta for a tag factor; null for a metric factor. */ + delta: number | null; + /** Raw Pearson r for a metric factor; null for a tag factor. */ + r: number | null; +} + +/** Max factors on the board so the headline surface stays scannable. */ +export const BETTER_DAYS_MAX_FACTORS = 8; + +/** Map a metric correlation strength label to the shared confidence band. */ +function metricConfidence( + strength: CorrelationResult["strength"], +): InfluenceConfidence { + if (strength === "stark") return "high"; + if (strength === "moderat") return "medium"; + return "low"; +} + +/** + * Merge the F1 tag influence rows and the mood × health-metric + * correlations into one effect-size-ranked, confidence-gated board. + * + * Inclusion gates (multiple-comparison aware): + * - Tag rows: already gated by `computeTagInfluence` (sample floors + + * Welch); included as-is. + * - Metric rows: only correlations that reached `n ≥ 5` AND carry a + * non-"keine" strength (|r| ≥ 0.2) are folded in — a near-zero r is not + * an association. + * + * Ranking is by `effectSize` desc, then confidence, then key for stability. + * The two sources are put on one comparable scale: metric rows use |r| + * (already unitless in [0,1]); tag rows use a standardized effect — the raw + * mood-point `delta` divided by the pooled with/without mood SD (Cohen's d), + * clamped to [0,1]. A |d| ≥ 1 (a mean shift of one full SD) saturates the + * scale, mirroring an |r| near its ceiling, so neither source dominates the + * board for scale reasons alone. When a tag has no pooled SD (both groups + * perfectly constant), it falls back to the legacy |delta|/2 heuristic — a + * rare degenerate case that can't be standardized. The raw delta / r the UI + * shows is unchanged; this only governs the sort order. Observational only. + */ +export function computeBetterDays( + tagInfluence: TagInfluence, + correlations: Record, +): BetterDayFactor[] { + const factors: BetterDayFactor[] = []; + + // Tag factors (both axes). Structured tags carry their label meta. + for (const row of [...tagInfluence.structured, ...tagInfluence.flat]) { + factors.push({ + source: "tag", + key: row.tag, + labelKey: row.labelKey, + label: row.label ?? null, + categoryKey: row.categoryKey, + icon: row.icon, + direction: row.delta >= 0 ? "up" : "down", + n: Math.min(row.withDays, row.withoutDays), + confidence: row.confidence, + // Cohen's-d standardization so the tag effect is commensurable with a + // metric |r|; fall back to the legacy |delta|/2 only when the pooled + // SD is unavailable (both groups perfectly constant). + effectSize: + row.pooledSd && row.pooledSd > 0 + ? Math.min(1, Math.abs(row.delta) / row.pooledSd) + : Math.min(1, Math.abs(row.delta) / 2), + delta: row.delta, + r: null, + }); + } + + // Metric factors — only meaningful, sufficiently-sampled correlations. + for (const [key, corr] of Object.entries(correlations)) { + const result = corr.result; + if (!result || corr.n < 5) continue; + if (result.strength === "keine") continue; + factors.push({ + source: "metric", + key, + labelKey: null, + label: null, + categoryKey: null, + icon: null, + // Mood is the x-axis: positive r = higher metric on higher-mood days, + // i.e. the metric is associated with higher mood. + direction: result.r >= 0 ? "up" : "down", + n: corr.n, + confidence: metricConfidence(result.strength), + effectSize: Math.min(1, Math.abs(result.r)), + delta: null, + r: result.r, + }); + } + + const confidenceRank: Record = { + high: 3, + medium: 2, + low: 1, + }; + + return factors + .sort( + (a, b) => + b.effectSize - a.effectSize || + confidenceRank[b.confidence] - confidenceRank[a.confidence] || + a.key.localeCompare(b.key), + ) + .slice(0, BETTER_DAYS_MAX_FACTORS); +} diff --git a/src/lib/insights/mood-crosstab.ts b/src/lib/insights/mood-crosstab.ts new file mode 100644 index 000000000..f03d49617 --- /dev/null +++ b/src/lib/insights/mood-crosstab.ts @@ -0,0 +1,689 @@ +/** + * Tag × health-metric and RATED-factor × health-metric crosstabs for + * the mood surface: with-vs-without (or low-vs-high median-split) metric + * deltas per structured tag / rated factor, Welch-tested and BH-FDR + * corrected as one family per board, with same-day and D → D+1 pairing + * modes. + * + * Extracted verbatim from `mood-aggregates.ts`, which re-exports this + * module so every existing call site keeps importing from the hub. + * Everything here is a pure function over already-fetched rows; the DB + * read + orchestration stay in the hub's `fetchMoodAggregates`. + */ + +import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; +import { benjaminiHochberg as fdrAdjust } from "@/lib/insights/correlation-discovery"; +import { welchTTest } from "@/lib/insights/correlations"; +import { round } from "@/lib/insights/status-shared"; +import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; +import { MS_PER_DAY } from "@/lib/time-constants"; +import { toBerlinYmd } from "@/lib/tz/resolver"; +import type { + CrossMetricMeasurement, + MoodAggregateEntry, + RatedFactorScore, + StructuredTagRef, +} from "@/lib/insights/mood-aggregates"; +import { + collapseToTaggedDays, + influenceConfidence, + type InfluenceConfidence, +} from "@/lib/insights/mood-tag-influence"; +import type { MeasurementType } from "@/generated/prisma/enums"; +import type { MeasurementSource } from "@/generated/prisma/client"; + +// --- Tag × health-metric crosstab (v1.12.0) ------------------------------ + +/** + * v1.12.0 — per-tag × health-metric crosstab. + * + * Daylio's "Activities & Mood" board answers "how does my mood differ on + * days I tagged X?". This extends the same with-vs-without comparison to a + * health METRIC: for each structured mood tag present on enough days, the + * metric's mean on tag-present days vs tag-absent days, the delta, the + * Welch two-sided p, and a confidence band — reusing the EXACT statistics + * engine the F1 tag-influence board already runs (`welchTTest` + + * `influenceConfidence` + the per-group day floors). FDR-corrected across + * every tested (tag × metric) pair via the shared `benjaminiHochberg` + * step-up so the surface stays honest as the matrix grows. + * + * Pairing modes: + * - "sameDay" — the metric on the SAME day the tag was logged. Used for + * activity (a workout/active tag × ACTIVE_ENERGY_BURNED) and sleep (a + * sleep tag × SLEEP_DURATION). + * - "nextDay" — the metric on the day AFTER the tag (D → D+1 lag join, the + * same lag the correlation-discovery engine uses). Used for an + * alcohol/food tag × next-day recovery/readiness (RECOVERY_SCORE), + * where the plausible direction is "tonight's choice → tomorrow's + * recovery". + * + * Observational only — the UI renders the standing "association, not + * cause" caption once. The metric value is shown in its display unit + * (SLEEP_DURATION minutes → hours; energy kcal; recovery score 0..100). + */ +/** Default Benjamini-Hochberg target FDR for the crosstab family. */ +export const CROSSTAB_FDR_Q = 0.1; + +/** + * Per-side day floors for the crosstab — kept in step with the influence + * floors (`INFLUENCE_MIN_PRESENT_DAYS` / `INFLUENCE_MIN_ABSENT_DAYS`) so a tag + * needs the same defensible present/absent support before a metric delta + * surfaces. Declared independently so the crosstab surface owns its own floor. + */ +export const CROSSTAB_MIN_PRESENT_DAYS = 5; +export const CROSSTAB_MIN_ABSENT_DAYS = 5; + +/** Max rows surfaced across the whole crosstab so the surface stays scannable. */ +export const CROSSTAB_MAX_ROWS = 8; + +type CrosstabMode = "sameDay" | "nextDay"; + +/** + * The metric channels the crosstab pairs each tag against. The `display` + * key drives unit formatting on the client; `mode` fixes the pairing lag. + * Single-sourced so the fetch filter (`CROSSTAB_METRIC_TYPES`) and the + * compute can never drift. + */ +export const CROSSTAB_METRICS: Record< + string, + { + type: MeasurementType; + mode: CrosstabMode; + display: "hours" | "kcal" | "score"; + } +> = { + // A workout / active tag × same-day active energy. + activeEnergy: { + type: "ACTIVE_ENERGY_BURNED", + mode: "sameDay", + display: "kcal", + }, + // A sleep tag × same-night sleep duration (stored minutes → hours on UI). + sleepDuration: { type: "SLEEP_DURATION", mode: "sameDay", display: "hours" }, + // An alcohol / food tag × next-day recovery/readiness (D → D+1 lag). + nextDayRecovery: { + type: "RECOVERY_SCORE", + mode: "nextDay", + display: "score", + }, +} as const; + +export type CrosstabMetricKey = keyof typeof CROSSTAB_METRICS; + +/** Distinct measurement types the crosstab reads — single-sourced. */ +export const CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from( + new Set(Object.values(CROSSTAB_METRICS).map((m) => m.type)), +); + +export interface TagMetricCrosstabRow { + /** Stable structured-tag key. */ + tag: string; + /** i18n label key for the tag (structured tags only — never flat). */ + labelKey: string; + /** Decrypted custom-tag label; null for catalogue tags. */ + label?: string | null; + /** Parent category key, for grouping/icon. */ + categoryKey: string; + /** Lucide icon name, or null. */ + icon: string | null; + /** Which metric channel this row compares against. */ + metricKey: CrosstabMetricKey; + /** Display unit hint for the client formatter. */ + display: "hours" | "kcal" | "score"; + /** Pairing mode used (echoed so the UI can caption "next-day"). */ + mode: CrosstabMode; + /** Days the tag was present that had a paired metric value. */ + withDays: number; + /** Days the tag was absent that had a paired metric value. */ + withoutDays: number; + /** Mean metric on tag-present days (display unit). */ + withAvg: number; + /** Mean metric on tag-absent days (display unit). */ + withoutAvg: number; + /** withAvg − withoutAvg (display unit). */ + delta: number; + /** Welch two-sided p-value for the difference of means. */ + pValue: number; + /** Benjamini-Hochberg adjusted q-value across the tested family. */ + qValue: number; + /** Discrete confidence band (p + min per-group day count). */ + confidence: InfluenceConfidence; +} + +const CROSSTAB_SUM_TYPES = new Set([ + "ACTIVE_ENERGY_BURNED", + "SLEEP_DURATION", + "ACTIVITY_STEPS", +]); + +/** Berlin-calendar day key (`YYYY-MM-DD`) for a row's `measuredAt`. */ +function berlinDayKey(measuredAt: Date): string { + const { year, month, day } = toBerlinYmd(measuredAt); + return `${year}-${month}-${day}`; +} + +/** + * Build a Berlin-day-keyed metric map with the right aggregation. Energy + * and step totals are SUMMED per day (HealthKit `stats:` rows are already + * one daily-total row, so the sum is the total either way); sleep duration + * is SUMMED across per-stage rows to get the night total; everything else + * (a once-daily score) is MEANED. Returns minutes/kcal/raw — the display + * conversion happens at row-build time. + * + * Cross-source de-dup: before bucketing, the rows for this metric run + * through the SAME canonical-source picker the analytics steps/sleep path + * uses (`pickCanonicalSourceRows`, keyed by `metricKeyForType` + the + * Berlin day key). Without it, the moment two sources report the same day + * (Fitbit + Apple steps, Fitbit + WHOOP sleep) the SUM channels would + * double-count and bias the Welch delta. The picker collapses each day to + * one source (and one device-type within it), so the sum reflects one + * stream. MEAN channels (a once-daily score like recovery) gain the same + * single-source guarantee for free. Rows without a `source` (the legacy + * test fixtures, or a metric whose source isn't in the ladder) fall + * through the picker's pass-through branch unchanged. + */ +export function metricDayMap( + measurements: CrossMetricMeasurement[], + type: string, + userPriorityJson: unknown, +): Map { + const summed = CROSSTAB_SUM_TYPES.has(type); + + const typeRows = measurements.filter((m) => m.type === type); + if (typeRows.length === 0) return new Map(); + + // Resolve the canonical source per day. `metricKeyForType` maps the + // crosstab's MeasurementType to its priority ladder; a metric with no + // ladder (or rows without a source) keeps every row via the picker's + // documented pass-through fallback, so behaviour is identical to the + // pre-fix sum for single-source data. + const metricKey = metricKeyForType(type as MeasurementType); + const canonicalRows = metricKey + ? pickCanonicalSourceRows( + typeRows.map((m) => ({ + measuredAt: m.measuredAt, + source: (m.source ?? "MANUAL") as MeasurementSource, + deviceType: m.deviceType ?? null, + type: type as MeasurementType, + value: m.value, + })), + metricKey, + userPriorityJson, + berlinDayKey, + ).canonicalRows + : typeRows; + + const byDay = new Map(); + for (const m of canonicalRows) { + const key = berlinDayKey(m.measuredAt); + const cur = byDay.get(key) ?? { sum: 0, count: 0 }; + cur.sum += m.value; + cur.count += 1; + byDay.set(key, cur); + } + const out = new Map(); + for (const [key, agg] of byDay) { + out.set(key, summed ? agg.sum : agg.sum / agg.count); + } + return out; +} + +/** Add `lagDays` to a YYYY-MM-DD day key (UTC-anchored, DST-immune). */ +export function shiftDayKey(day: string, lagDays: number): string { + const [y, m, d] = day.split("-").map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + dt.setUTCDate(dt.getUTCDate() + lagDays); + return dt.toISOString().slice(0, 10); +} + +/** Convert a raw metric value to the row's display unit. */ +function toDisplayUnit( + value: number, + display: "hours" | "kcal" | "score", +): number { + return display === "hours" ? value / 60 : value; +} + +interface CrosstabCandidate { + row: Omit; +} + +/** + * Compute the tag × metric crosstab. Pure over already-fetched rows. + * + * For every structured tag and every configured metric channel, collapse + * the window to one observation per tz-anchored day (the tag's daily + * membership) joined to the metric's per-day value (same-day or D+1). The + * tag-present and tag-absent metric samples feed `welchTTest`; rows that + * clear both day floors are tested, FDR-corrected as one family, and the + * survivors (p < 0.05 AND q ≤ `CROSSTAB_FDR_Q`) surface ranked by q then + * |delta|. + * + * Structured tags only — flat free-text tags are excluded because the + * crosstab needs a stable, localized label and a curated-catalog tag is + * the right granularity for a metric comparison. + */ +export function computeTagMetricCrosstab(args: { + entries: MoodAggregateEntry[]; + measurements: CrossMetricMeasurement[]; + now: Date; + windowDays?: number; + /** + * The user's source-priority blob. Threaded into `metricDayMap` so the + * per-day canonical-source pick honours the user's ladder. `null` (the + * test default) resolves to the default ladders. + */ + userPriorityJson?: unknown; +}): TagMetricCrosstabRow[] { + const { entries, measurements, now } = args; + const windowDays = args.windowDays ?? 365; + const userPriorityJson = args.userPriorityJson ?? null; + + const dayMap = collapseToTaggedDays(entries, now, windowDays); + if (dayMap.size === 0) return []; + + // The set of structured tags seen in the window, with their meta. + const structuredMeta = new Map(); + for (const day of dayMap.values()) { + for (const [key, ref] of day.structuredTags) { + if (!structuredMeta.has(key)) structuredMeta.set(key, ref); + } + } + if (structuredMeta.size === 0) return []; + + const candidates: CrosstabCandidate[] = []; + + for (const [metricKey, cfg] of Object.entries(CROSSTAB_METRICS) as Array< + [CrosstabMetricKey, (typeof CROSSTAB_METRICS)[CrosstabMetricKey]] + >) { + const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson); + if (metricByDay.size === 0) continue; + + for (const [tagKey, ref] of structuredMeta) { + const withVals: number[] = []; + const withoutVals: number[] = []; + for (const [dayKey, day] of dayMap) { + const metricKeyDay = + cfg.mode === "nextDay" ? shiftDayKey(dayKey, 1) : dayKey; + const metricValue = metricByDay.get(metricKeyDay); + if (metricValue == null || !Number.isFinite(metricValue)) continue; + const display = toDisplayUnit(metricValue, cfg.display); + if (day.structuredTags.has(tagKey)) withVals.push(display); + else withoutVals.push(display); + } + + if ( + withVals.length < CROSSTAB_MIN_PRESENT_DAYS || + withoutVals.length < CROSSTAB_MIN_ABSENT_DAYS + ) { + continue; + } + + const welch = welchTTest(withVals, withoutVals); + const withAvg = withVals.reduce((s, v) => s + v, 0) / withVals.length; + const withoutAvg = + withoutVals.reduce((s, v) => s + v, 0) / withoutVals.length; + const delta = withAvg - withoutAvg; + if (delta === 0) continue; + + const pValue = welch.status === "ok" ? welch.pValue : 1; + const minGroupDays = Math.min(withVals.length, withoutVals.length); + + candidates.push({ + row: { + tag: tagKey, + labelKey: ref.labelKey, + label: ref.label ?? null, + categoryKey: ref.categoryKey, + icon: ref.icon, + metricKey, + display: cfg.display, + mode: cfg.mode, + withDays: withVals.length, + withoutDays: withoutVals.length, + withAvg: round(withAvg, 2), + withoutAvg: round(withoutAvg, 2), + delta: round(delta, 2), + pValue, + confidence: influenceConfidence(pValue, minGroupDays), + }, + }); + } + } + + if (candidates.length === 0) return []; + + // FDR-correct across the whole tested family (every tag × metric pair) so + // the multiple-comparison surface stays honest as the matrix grows. Reuses + // the same Benjamini-Hochberg step-up the correlation-discovery engine runs. + const qValues = fdrAdjust(candidates.map((c) => c.row.pValue)); + + return candidates + .map((c, i) => ({ ...c.row, qValue: Math.round(qValues[i] * 1000) / 1000 })) + .filter((row) => row.pValue < 0.05 && row.qValue <= CROSSTAB_FDR_Q) + .sort( + (a, b) => + a.qValue - b.qValue || + Math.abs(b.delta) - Math.abs(a.delta) || + a.tag.localeCompare(b.tag), + ) + .slice(0, CROSSTAB_MAX_ROWS); +} + +// --- RATED factor × health-metric crosstab (low- vs high-factor days) --- + +/** + * v1.14.0 — the flagship cross-domain insight: "on days you rated + * low, your ran X below baseline". + * + * A RATED mood factor (work / sleep-quality / stress …) is a CONTINUOUS + * per-day score, unlike the BINARY structured tags `computeTagMetricCrosstab` + * handles. To reuse the EXACT same Welch + FDR engine, this thresholds the + * factor's daily mean into a binary membership — a day is "low" when its + * factor mean sits BELOW the factor's own median over the window, "high" at + * or above. The median split is robust + self-calibrating (always two + * non-empty groups when the factor has spread), and invents no fixed cutoff. + * + * `inverse` is applied at the SERIES boundary (the factor's per-day mean is + * flipped to `(scaleMin + scaleMax) - raw` for an inverse factor like + * stress), so the median split — and therefore the "low day" label — always + * means "a worse day for this factor", honestly, without per-call casing. + * + * Same honesty discipline as the tag crosstab: both sides need + * `CROSSTAB_MIN_PRESENT_DAYS` / `CROSSTAB_MIN_ABSENT_DAYS` paired metric + * days, the family is BH-FDR corrected as one (across every factor × metric + * × direction pair), survivors clear p < 0.05 AND q ≤ `CROSSTAB_FDR_Q`, and + * the surface is capped + ranked. Observational only — the card renders the + * standing "association, not cause" caption. + */ + +/** + * The metric channels the factor crosstab pairs each RATED factor against. + * Broader than the tag crosstab (which is activity-focused) because the + * value of a RATED factor is its bridge from a SUBJECTIVE score to an + * OBJECTIVE vital. `sameDay` for same-night/same-day metrics (sleep, + * steps); `nextDay` (D → D+1) for overnight-recovery metrics (RHR / HRV), + * matching the plausible "today's factor → tomorrow's body" direction. + */ +export const FACTOR_CROSSTAB_METRICS: Record< + string, + { + type: MeasurementType; + mode: CrosstabMode; + display: "hours" | "score" | "steps" | "bpm" | "ms" | "kg" | "mmHg"; + } +> = { + sleepDuration: { type: "SLEEP_DURATION", mode: "sameDay", display: "hours" }, + steps: { type: "ACTIVITY_STEPS", mode: "sameDay", display: "steps" }, + restingHeartRate: { + type: "RESTING_HEART_RATE", + mode: "nextDay", + display: "bpm", + }, + heartRateVariability: { + type: "HEART_RATE_VARIABILITY", + mode: "nextDay", + display: "ms", + }, + weight: { type: "WEIGHT", mode: "sameDay", display: "kg" }, + bloodPressureSystolic: { + type: "BLOOD_PRESSURE_SYS", + mode: "sameDay", + display: "mmHg", + }, +} as const; + +export type FactorCrosstabMetricKey = keyof typeof FACTOR_CROSSTAB_METRICS; + +/** Distinct measurement types the factor crosstab reads — single-sourced. */ +export const FACTOR_CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from( + new Set(Object.values(FACTOR_CROSSTAB_METRICS).map((m) => m.type)), +); + +export interface FactorMetricCrosstabRow { + /** Stable RATED-factor key. */ + factor: string; + /** i18n label key for the factor. */ + labelKey: string; + /** Parent category key, for grouping/icon. */ + categoryKey: string; + /** Lucide icon name, or null. */ + icon: string | null; + /** + * `true` when the factor is inverse-scaled (stress / conflict). The + * UI flips the phrasing — "your worse days" — but the split + * itself already runs on the flipped series, so a "low" row always + * means a worse day regardless. + */ + inverse: boolean; + /** Which metric channel this row compares against. */ + metricKey: FactorCrosstabMetricKey; + /** Display unit hint for the client formatter. */ + display: FactorMetricDisplay; + /** Pairing mode used (echoed so the UI can caption "next-day"). */ + mode: CrosstabMode; + /** Days the factor was rated LOW (below its median) with a paired metric. */ + lowDays: number; + /** Days the factor was rated HIGH (at/above its median) with a paired metric. */ + highDays: number; + /** Mean metric on low-factor days (display unit). */ + lowAvg: number; + /** Mean metric on high-factor days (display unit). */ + highAvg: number; + /** lowAvg − highAvg (display unit). Negative = vital runs lower on low days. */ + delta: number; + /** Welch two-sided p-value for the difference of means. */ + pValue: number; + /** Benjamini-Hochberg adjusted q-value across the tested family. */ + qValue: number; + /** Discrete confidence band (p + min per-group day count). */ + confidence: InfluenceConfidence; +} + +type FactorMetricDisplay = + (typeof FACTOR_CROSSTAB_METRICS)[FactorCrosstabMetricKey]["display"]; + +/** Convert a raw metric value to its factor-crosstab display unit. */ +function toFactorDisplayUnit( + value: number, + display: FactorMetricDisplay, +): number { + return display === "hours" ? value / 60 : value; +} + +/** Median of a numeric array (sorted-copy, mean-of-two for even length). */ +function median(values: number[]): number { + if (values.length === 0) return Number.NaN; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +/** Reference data for one RATED factor's daily series + its meta. */ +interface FactorDailySeries { + ref: Pick< + RatedFactorScore, + "key" | "labelKey" | "categoryKey" | "icon" | "inverse" + >; + /** Day key → factor's daily-mean score, inverse-flipped if needed. */ + byDay: Map; +} + +/** + * Build a per-factor daily-mean series, applying the `inverse` sign-flip + * once. For each tz-anchored day, the mean of the factor's ratings across + * the day's entries; an inverse factor's rating `r` maps to + * `(scaleMin + scaleMax) - r` BEFORE averaging so "up" always reads as a + * better day. Pure. Exported for the discovery-channel path (period + * narrative) so the two consumers can never diverge on the flip. + */ +export function buildFactorDailySeries( + entries: MoodAggregateEntry[], + now: Date, + windowDays: number, +): Map { + const cutoff = now.getTime() - windowDays * MS_PER_DAY; + const acc = new Map< + string, + { + ref: FactorDailySeries["ref"]; + byDay: Map; + } + >(); + for (const entry of entries) { + if (entry.moodLoggedAt.getTime() < cutoff) continue; + if (!entry.ratedFactors) continue; + for (const f of entry.ratedFactors) { + if (!Number.isFinite(f.rating)) continue; + // The documented sign-flip: an inverse factor's rating is mirrored + // across its scale midpoint so a higher series value always means a + // better day. Done here, once, at the series boundary. + const value = f.inverse ? f.scaleMin + f.scaleMax - f.rating : f.rating; + const slot = acc.get(f.key) ?? { + ref: { + key: f.key, + labelKey: f.labelKey, + categoryKey: f.categoryKey, + icon: f.icon, + inverse: f.inverse, + }, + byDay: new Map(), + }; + const cur = slot.byDay.get(entry.date) ?? { sum: 0, count: 0 }; + cur.sum += value; + cur.count += 1; + slot.byDay.set(entry.date, cur); + acc.set(f.key, slot); + } + } + const out = new Map(); + for (const [key, slot] of acc) { + const byDay = new Map(); + for (const [day, agg] of slot.byDay) byDay.set(day, agg.sum / agg.count); + out.set(key, { ref: slot.ref, byDay }); + } + return out; +} + +interface FactorCrosstabCandidate { + row: Omit; +} + +/** + * Compute the RATED-factor × metric crosstab. Pure over already-fetched + * rows. Mirrors `computeTagMetricCrosstab` but splits each factor's + * continuous daily score into low/high by its own median, then runs the + * same Welch + FDR engine. + */ +export function computeFactorMetricCrosstab(args: { + entries: MoodAggregateEntry[]; + measurements: CrossMetricMeasurement[]; + now: Date; + windowDays?: number; + userPriorityJson?: unknown; +}): FactorMetricCrosstabRow[] { + const { entries, measurements, now } = args; + const windowDays = args.windowDays ?? 365; + const userPriorityJson = args.userPriorityJson ?? null; + + const factorSeries = buildFactorDailySeries(entries, now, windowDays); + if (factorSeries.size === 0) return []; + + const candidates: FactorCrosstabCandidate[] = []; + + for (const [metricKey, cfg] of Object.entries( + FACTOR_CROSSTAB_METRICS, + ) as Array< + [ + FactorCrosstabMetricKey, + (typeof FACTOR_CROSSTAB_METRICS)[FactorCrosstabMetricKey], + ] + >) { + const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson); + if (metricByDay.size === 0) continue; + + for (const [factorKey, series] of factorSeries) { + // The median split is over the factor's own rated days only, so a + // sparse factor never borrows another's threshold. + const ratedDays = [...series.byDay.values()]; + if ( + ratedDays.length < + CROSSTAB_MIN_PRESENT_DAYS + CROSSTAB_MIN_ABSENT_DAYS + ) { + continue; + } + const split = median(ratedDays); + + const lowVals: number[] = []; + const highVals: number[] = []; + for (const [dayKey, score] of series.byDay) { + const metricDayKey = + cfg.mode === "nextDay" ? shiftDayKey(dayKey, 1) : dayKey; + const metricValue = metricByDay.get(metricDayKey); + if (metricValue == null || !Number.isFinite(metricValue)) continue; + const display = toFactorDisplayUnit(metricValue, cfg.display); + // Below the median is a "low" (worse, after the inverse flip) day; + // at or above is "high". A perfectly bimodal factor with all days + // exactly on the median falls entirely into "high" and fails the + // low-side floor below — honest: no contrast, no row. + if (score < split) lowVals.push(display); + else highVals.push(display); + } + + if ( + lowVals.length < CROSSTAB_MIN_PRESENT_DAYS || + highVals.length < CROSSTAB_MIN_ABSENT_DAYS + ) { + continue; + } + + const welch = welchTTest(lowVals, highVals); + const lowAvg = lowVals.reduce((s, v) => s + v, 0) / lowVals.length; + const highAvg = highVals.reduce((s, v) => s + v, 0) / highVals.length; + const delta = lowAvg - highAvg; + if (delta === 0) continue; + + const pValue = welch.status === "ok" ? welch.pValue : 1; + const minGroupDays = Math.min(lowVals.length, highVals.length); + + candidates.push({ + row: { + factor: factorKey, + labelKey: series.ref.labelKey, + categoryKey: series.ref.categoryKey, + icon: series.ref.icon, + inverse: series.ref.inverse, + metricKey, + display: cfg.display, + mode: cfg.mode, + lowDays: lowVals.length, + highDays: highVals.length, + lowAvg: round(lowAvg, 2), + highAvg: round(highAvg, 2), + delta: round(delta, 2), + pValue, + confidence: influenceConfidence(pValue, minGroupDays), + }, + }); + } + } + + if (candidates.length === 0) return []; + + // One BH family across every factor × metric pair tested — the same + // step-up the tag crosstab + discovery engine run. + const qValues = fdrAdjust(candidates.map((c) => c.row.pValue)); + + return candidates + .map((c, i) => ({ ...c.row, qValue: Math.round(qValues[i] * 1000) / 1000 })) + .filter((row) => row.pValue < 0.05 && row.qValue <= CROSSTAB_FDR_Q) + .sort( + (a, b) => + a.qValue - b.qValue || + Math.abs(b.delta) - Math.abs(a.delta) || + a.factor.localeCompare(b.factor), + ) + .slice(0, CROSSTAB_MAX_ROWS); +} diff --git a/src/lib/insights/mood-patterns.ts b/src/lib/insights/mood-patterns.ts new file mode 100644 index 000000000..86351aacf --- /dev/null +++ b/src/lib/insights/mood-patterns.ts @@ -0,0 +1,308 @@ +/** + * Distribution / rhythm calculators for the mood surface: in-target + * share, discrete-level distribution, weekday averages, the tz-aware + * time-of-day pattern, and the day-to-day stability score. + * + * Extracted verbatim from `mood-aggregates.ts`, which re-exports this + * module so every existing call site keeps importing from the hub. + * Everything here is a pure function over already-bucketed daily points + * or raw mood rows; the DB read + orchestration stay in the hub's + * `fetchMoodAggregates`. + */ + +import { dayOffsetToBerlinDayKey } from "@/lib/insights/bucket-series"; +import { + MOOD_GREEN_MAX, + MOOD_GREEN_MIN, + type DailyPoint, + type MoodAggregateEntry, +} from "@/lib/insights/mood-aggregates"; +import { round } from "@/lib/insights/status-shared"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; + +// --- In-target % over the last 30 daily points (lifted from mood-status) --- + +/** + * Share (0..100) of the newest 30 daily mood buckets that land in the + * green band (>= 3.5). Returns null when there is no recent data. + */ +export function computeInTargetPct(daily: DailyPoint[]): number | null { + const recent = daily.filter((bucket) => bucket.dayOffset < 30); + if (recent.length === 0) return null; + const inTarget = recent.filter( + (entry) => entry.value >= MOOD_GREEN_MIN && entry.value <= MOOD_GREEN_MAX, + ).length; + return round((inTarget / recent.length) * 100, 1); +} + +// --- Mood distribution (share per discrete level) --- + +export interface DistributionRow { + /** Rounded mood level 1..5. */ + score: number; + count: number; +} + +/** + * Distribution of daily-mean mood across the five discrete levels. + * + * Open product question #2 (design §7) resolves to the daily-mean + * convention used everywhere else on the surface: each day contributes + * one observation, its mean rounded to the nearest level. This keeps + * multi-entry days from over-weighting the histogram and matches the + * heatmap (one cell per day). + */ +export function computeDistribution(daily: DailyPoint[]): DistributionRow[] { + const counts = new Map(); + for (const bucket of daily) { + const level = Math.min(5, Math.max(1, Math.round(bucket.value))); + counts.set(level, (counts.get(level) ?? 0) + 1); + } + const rows: DistributionRow[] = []; + for (let score = 1; score <= 5; score++) { + rows.push({ score, count: counts.get(score) ?? 0 }); + } + return rows; +} + +// --- Average mood by weekday --- + +export interface WeekdayRow { + /** 0 = Monday … 6 = Sunday. */ + weekday: number; + avgScore: number | null; + count: number; +} + +/** + * Average daily-mean mood grouped by weekday (Monday = 0). The weekday + * is read off the day-key in UTC so it matches the heatmap's UTC-anchored + * Monday alignment (`compliance-heatmap` reads `getUTCDay`). + */ +export function computeWeekdayAverages( + daily: DailyPoint[], + now: Date, + tz: string = DEFAULT_TIMEZONE, +): WeekdayRow[] { + const sums = new Map(); + for (const bucket of daily) { + const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset, tz); + const d = new Date(dayKey + "T00:00:00Z"); + const weekday = (d.getUTCDay() + 6) % 7; // Monday = 0 + const cur = sums.get(weekday) ?? { sum: 0, count: 0 }; + cur.sum += bucket.value; + cur.count += 1; + sums.set(weekday, cur); + } + const rows: WeekdayRow[] = []; + for (let weekday = 0; weekday < 7; weekday++) { + const agg = sums.get(weekday); + rows.push({ + weekday, + avgScore: agg ? round(agg.sum / agg.count, 2) : null, + count: agg?.count ?? 0, + }); + } + return rows; +} + +// --- Time-of-day pattern (tz-aware part-of-day buckets) --- + +/** Part-of-day bucket key. Order is morning → night for stable rendering. */ +export type TimeOfDayBucket = "morning" | "afternoon" | "evening" | "night"; + +/** Ordered bucket list — drives both the chart x-axis and the iteration. */ +export const TIME_OF_DAY_BUCKETS: readonly TimeOfDayBucket[] = [ + "morning", + "afternoon", + "evening", + "night", +] as const; + +/** + * Minimum entries a single bucket must hold before it counts toward the + * pattern. A lone log in a bucket is noise, not a daypart preference. + */ +export const TIME_OF_DAY_MIN_BUCKET_SAMPLES = 3; + +/** + * Minimum distinct populated buckets (each at or above the per-bucket + * sample floor) before the pattern surfaces at all. The guard against the + * once-a-day logger: a nightly Telegram check-in clusters in a single + * bucket, which can never clear a two-bucket spread, so the "you feel + * best in the morning" takeaway never fires misleadingly. + */ +export const TIME_OF_DAY_MIN_SPREAD = 2; + +/** + * Map a local hour-of-day (0..23) to its part-of-day bucket. + * + * morning 05:00–11:59, afternoon 12:00–16:59, evening 17:00–20:59, + * night 21:00–04:59. The boundaries follow the common Daylio / consumer + * convention; night wraps midnight so a 02:00 log lands in `night`. + */ +export function bucketForHour(hour: number): TimeOfDayBucket { + if (hour >= 5 && hour < 12) return "morning"; + if (hour >= 12 && hour < 17) return "afternoon"; + if (hour >= 17 && hour < 21) return "evening"; + return "night"; +} + +export interface TimeOfDayRow { + bucket: TimeOfDayBucket; + avgScore: number | null; + count: number; +} + +export interface TimeOfDayPattern { + /** All four buckets, in canonical order; unpopulated buckets carry null. */ + buckets: TimeOfDayRow[]; + /** + * Whether the spread + sample floors are cleared — i.e. the pattern is + * trustworthy enough to surface a chart and a takeaway. False for the + * once-a-day logger (everything in one bucket) or a sparse history. + */ + reliable: boolean; + /** Best / worst bucket keys when `reliable`; null otherwise. */ + best: TimeOfDayBucket | null; + worst: TimeOfDayBucket | null; +} + +/** + * Average mood per part of day, bucketed in each entry's own timezone. + * + * `moodLoggedAt` carries the exact instant; `tz` (per-row IANA) anchors + * the local hour. Legacy rows without a `tz` fall back to UTC — the same + * convention the rollup tier uses for `tz IS NULL` mood rows. Every entry + * (not the daily mean) feeds its bucket, because the question is "what + * time of day do I feel best", which a daily collapse would erase. + * + * The `reliable` flag is the once-a-day-logger guard: it only trips when + * at least `TIME_OF_DAY_MIN_SPREAD` buckets each carry + * `TIME_OF_DAY_MIN_BUCKET_SAMPLES` entries. `best`/`worst` are computed + * over the populated-and-sufficient buckets only. + */ +export function computeTimeOfDayAverages( + entries: MoodAggregateEntry[], +): TimeOfDayPattern { + const sums = new Map(); + for (const entry of entries) { + const tz = entry.tz ?? "UTC"; + const { hour } = wallClockInTz(entry.moodLoggedAt, tz); + const bucket = bucketForHour(hour); + const cur = sums.get(bucket) ?? { sum: 0, count: 0 }; + cur.sum += entry.score; + cur.count += 1; + sums.set(bucket, cur); + } + + const buckets: TimeOfDayRow[] = TIME_OF_DAY_BUCKETS.map((bucket) => { + const agg = sums.get(bucket); + return { + bucket, + avgScore: agg ? round(agg.sum / agg.count, 2) : null, + count: agg?.count ?? 0, + }; + }); + + const sufficient = buckets.filter( + (row): row is TimeOfDayRow & { avgScore: number } => + row.avgScore != null && row.count >= TIME_OF_DAY_MIN_BUCKET_SAMPLES, + ); + const reliable = sufficient.length >= TIME_OF_DAY_MIN_SPREAD; + + let best: TimeOfDayBucket | null = null; + let worst: TimeOfDayBucket | null = null; + if (reliable) { + let bestRow = sufficient[0]; + let worstRow = sufficient[0]; + for (const row of sufficient) { + if (row.avgScore > bestRow.avgScore) bestRow = row; + if (row.avgScore < worstRow.avgScore) worstRow = row; + } + best = bestRow.bucket; + worst = worstRow.bucket; + } + + return { buckets, reliable, best, worst }; +} + +// --- Mood stability score (variance of daily means → 0..100) --- + +/** + * Minimum distinct daily points before a stability score is computed. A + * handful of days has no meaningful variance signal, so a sparse logger + * gets `null` (no tile, no sentence) rather than a noisy number. + */ +export const STABILITY_MIN_DAYS = 7; + +/** + * The full-scale standard deviation that maps to a 0 stability score. The + * mood scale spans 1..5, so the widest day-to-day swing is 4 points; a + * population SD at or above this is treated as maximally unstable. Below + * it, the score scales linearly toward 100 (perfectly steady). + */ +export const STABILITY_SD_FULL_SCALE = 1.5; + +export type StabilityBand = + "verySteady" | "steady" | "variable" | "veryVariable"; + +export interface MoodStability { + /** 0..100; higher = steadier (lower day-to-day variance). */ + score: number; + /** Population standard deviation of the daily means (raw, for tests). */ + stdDev: number; + /** Descriptive, non-judgemental band. */ + band: StabilityBand; + /** Daily points the score was computed over. */ + days: number; +} + +/** + * Map a 0..100 stability score to a four-band descriptive label. + * Descriptive, not judgemental — some variation is healthy (Oura framing), + * so the bands read "steady" / "variable", never "good" / "bad". + */ +function stabilityBand(score: number): StabilityBand { + if (score >= 80) return "verySteady"; + if (score >= 60) return "steady"; + if (score >= 40) return "variable"; + return "veryVariable"; +} + +/** + * Mood-stability score from the population standard deviation of the + * daily means. + * + * Formula: + * sd = sqrt( mean( (x_i - mean(x))^2 ) ) // population SD + * score = round( 100 * (1 - min(sd, FULL) / FULL) ) // clamped 0..100 + * + * A flat mood (sd = 0) scores 100; an sd at or beyond + * `STABILITY_SD_FULL_SCALE` scores 0. Returns `null` below + * `STABILITY_MIN_DAYS` distinct daily points so a sparse logger never + * gets a meaningless score. + */ +export function computeMoodStability( + daily: DailyPoint[], +): MoodStability | null { + if (daily.length < STABILITY_MIN_DAYS) return null; + + const values = daily.map((bucket) => bucket.value); + const avg = values.reduce((sum, v) => sum + v, 0) / values.length; + const variance = + values.reduce((sum, v) => sum + (v - avg) ** 2, 0) / values.length; + const stdDev = Math.sqrt(variance); + + const normalised = + Math.min(stdDev, STABILITY_SD_FULL_SCALE) / STABILITY_SD_FULL_SCALE; + const score = Math.round(100 * (1 - normalised)); + + return { + score, + stdDev: round(stdDev, 3), + band: stabilityBand(score), + days: daily.length, + }; +} diff --git a/src/lib/insights/mood-tag-influence.ts b/src/lib/insights/mood-tag-influence.ts new file mode 100644 index 000000000..982b10bf3 --- /dev/null +++ b/src/lib/insights/mood-tag-influence.ts @@ -0,0 +1,434 @@ +/** + * Tag analytics for the mood surface: frequency + per-tag average mood + * on both axes (flat free-text and the structured taxonomy) plus the + * tag "Influence on Mood" board — with-vs-without daily-mean deltas, + * Welch-tested, confidence-banded. + * + * Extracted verbatim from `mood-aggregates.ts`, which re-exports this + * module so every existing call site keeps importing from the hub. + * Everything here is a pure function over already-fetched rows; the DB + * read + orchestration stay in the hub's `fetchMoodAggregates`. + */ + +import { welchTTest } from "@/lib/insights/correlations"; +import { round } from "@/lib/insights/status-shared"; +import { MS_PER_DAY } from "@/lib/time-constants"; +import type { + MoodAggregateEntry, + StructuredTagRef, +} from "@/lib/insights/mood-aggregates"; + +// --- Tag frequency + per-tag avg score (lifted from mood-status) --- + +export interface TagSummaryRow { + tag: string; + count: number; + avgScore: number; +} + +/** + * Tag frequency + per-tag average mood over a recency window. + * + * Keeps the v1.4.5 ~90-day window so the model still gets a recency- + * weighted view of tag patterns; the caller slices by date, not by + * record count. Only tags with count >= 2 survive (a single mention is + * noise), top 10 by frequency. + */ +export function computeTagSummary( + entries: MoodAggregateEntry[], + now: Date, + windowDays = 90, +): TagSummaryRow[] { + const cutoff = now.getTime() - windowDays * MS_PER_DAY; + const tagCounts = new Map(); + for (const entry of entries) { + if (entry.moodLoggedAt.getTime() < cutoff) continue; + if (entry.tags && Array.isArray(entry.tags)) { + for (const tag of entry.tags as string[]) { + const current = tagCounts.get(tag) ?? { count: 0, scoreSum: 0 }; + current.count += 1; + current.scoreSum += entry.score; + tagCounts.set(tag, current); + } + } + } + return Array.from(tagCounts.entries()) + .filter(([, stats]) => stats.count >= 2) + .sort((a, b) => b[1].count - a[1].count) + .slice(0, 10) + .map(([tag, stats]) => ({ + tag, + count: stats.count, + avgScore: round(stats.scoreSum / stats.count, 2), + })); +} + +// --- Structured-tag frequency + per-tag avg score (v1.8.5 taxonomy) --- + +export interface StructuredTagRow { + key: string; + categoryKey: string; + labelKey: string; + /** Decrypted custom-tag label; null for catalogue tags (see `StructuredTagRef`). */ + label?: string | null; + icon: string | null; + count: number; + avgScore: number; +} + +/** + * Frequency + per-tag average mood for the structured taxonomy tags, + * over the same recency window as the flat-tag summary. + * + * Unlike `computeTagSummary` (flat free text), singletons are KEPT — a + * structured tag comes from a curated catalog the user deliberately + * picked, so a single occurrence is signal, not noise. Ranked by + * frequency desc, then label key for a stable order. The category key + * rides along so the UI can group the bars by category with icons. + */ +export function computeStructuredTagSummary( + entries: MoodAggregateEntry[], + now: Date, + windowDays = 90, +): StructuredTagRow[] { + const cutoff = now.getTime() - windowDays * MS_PER_DAY; + const byKey = new Map< + string, + { + categoryKey: string; + labelKey: string; + label: string | null; + icon: string | null; + count: number; + scoreSum: number; + } + >(); + for (const entry of entries) { + if (entry.moodLoggedAt.getTime() < cutoff) continue; + if (!entry.structuredTags) continue; + for (const tag of entry.structuredTags) { + const current = byKey.get(tag.key) ?? { + categoryKey: tag.categoryKey, + labelKey: tag.labelKey, + label: tag.label ?? null, + icon: tag.icon, + count: 0, + scoreSum: 0, + }; + current.count += 1; + current.scoreSum += entry.score; + byKey.set(tag.key, current); + } + } + return Array.from(byKey.entries()) + .map(([key, stats]) => ({ + key, + categoryKey: stats.categoryKey, + labelKey: stats.labelKey, + label: stats.label, + icon: stats.icon, + count: stats.count, + avgScore: round(stats.scoreSum / stats.count, 2), + })) + .sort((a, b) => b.count - a.count || a.labelKey.localeCompare(b.labelKey)); +} + +// --- Tag "Influence on Mood" — with-vs-without daily-mean delta (F1) --- + +/** + * Minimum days a tag must be PRESENT on, and minimum days it must be + * ABSENT on, before its with/without influence surfaces. Both floors + * apply: a tag seen on 4 days has no defensible "with" average, and a + * tag present on all-but-2 days has no defensible "without" baseline. + * Five days per side is the same order as the stability floor (7 daily + * points) but per-group — enough for a mean of daily means to mean + * something without surfacing a two-day fluke as a "factor". + */ +export const INFLUENCE_MIN_PRESENT_DAYS = 5; +export const INFLUENCE_MIN_ABSENT_DAYS = 5; + +/** + * Maximum influence rows surfaced per axis (flat / structured). Ranked by + * absolute delta so the strongest associations lead; the cap keeps the + * multiple-comparison surface bounded (the confidence band downgrades the + * rest honestly). + */ +export const INFLUENCE_MAX_ROWS = 8; + +/** Discrete confidence the UI chip renders for an influence row. */ +export type InfluenceConfidence = "low" | "medium" | "high"; + +export interface TagInfluenceRow { + /** Stable tag key. For flat tags this is the free-text string. */ + tag: string; + /** + * For structured tags, the i18n label key + parent category so the UI + * can render the localized label with its icon. `null` for flat tags + * (rendered verbatim). + */ + labelKey: string | null; + /** Decrypted custom-tag label; null for catalogue / flat tags. */ + label?: string | null; + categoryKey: string | null; + icon: string | null; + /** Days the tag was present (daily-mean convention). */ + withDays: number; + /** Days the tag was absent over the same window. */ + withoutDays: number; + /** Mean of daily means on days the tag was present. */ + withAvg: number; + /** Mean of daily means on days the tag was absent (the counterfactual). */ + withoutAvg: number; + /** withAvg − withoutAvg, rounded. Positive = higher mood with the tag. */ + delta: number; + /** + * Pooled mood SD across the with/without groups (Cohen's-d denominator). + * `null` when neither group has testable spread (both constant). Lets the + * board standardize the raw delta into a unitless effect comparable to a + * Pearson |r|. Not surfaced in the UI — ranking only. + */ + pooledSd: number | null; + /** Welch two-sided p-value for the difference of means. */ + pValue: number; + /** Discrete confidence band derived from p-value + per-group sample size. */ + confidence: InfluenceConfidence; +} + +/** + * Map a Welch p-value + the smaller per-group day count to a discrete + * confidence band. Both inputs matter: a small p on tiny groups is still + * fragile, and a comfortable sample with a borderline p is only suggestive. + * + * - high : p < 0.01 AND both groups ≥ 12 days + * - medium : p < 0.05 AND both groups ≥ 8 days + * - low : everything else that cleared the surfacing floors + * + * The deterministic "no_variance" case (both groups perfectly constant but + * with a non-zero delta) is treated as `low` — a real but un-tested + * difference should never read as confident. + */ +export function influenceConfidence( + pValue: number, + minGroupDays: number, +): InfluenceConfidence { + if (pValue < 0.01 && minGroupDays >= 12) return "high"; + if (pValue < 0.05 && minGroupDays >= 8) return "medium"; + return "low"; +} + +/** + * Collapse raw mood rows to one observation per tz-anchored day: the + * day's mean score plus the union of every flat + structured tag key + * present on any entry that day. Daily-mean convention so a multi-entry + * day never over-weights an influence comparison (it matches the heatmap, + * distribution, and weekday axes). + */ +export interface TaggedDay { + /** Day's mean mood (mean of the day's entry scores). */ + mean: number; + /** Flat free-text tag keys present that day. */ + flatTags: Set; + /** Structured tag refs present that day, keyed for de-dup. */ + structuredTags: Map; +} + +export function collapseToTaggedDays( + entries: MoodAggregateEntry[], + now: Date, + windowDays: number, +): Map { + const cutoff = now.getTime() - windowDays * MS_PER_DAY; + const byDay = new Map< + string, + { + sum: number; + count: number; + flat: Set; + structured: Map; + } + >(); + for (const entry of entries) { + if (entry.moodLoggedAt.getTime() < cutoff) continue; + const day = byDay.get(entry.date) ?? { + sum: 0, + count: 0, + flat: new Set(), + structured: new Map(), + }; + day.sum += entry.score; + day.count += 1; + if (entry.tags && Array.isArray(entry.tags)) { + for (const tag of entry.tags as string[]) day.flat.add(tag); + } + if (entry.structuredTags) { + for (const ref of entry.structuredTags) day.structured.set(ref.key, ref); + } + byDay.set(entry.date, day); + } + const out = new Map(); + for (const [date, agg] of byDay) { + out.set(date, { + mean: agg.sum / agg.count, + flatTags: agg.flat, + structuredTags: agg.structured, + }); + } + return out; +} + +/** + * Compute the with-vs-without influence row for ONE candidate tag over a + * pre-collapsed day map. `presentOn(day)` decides membership. Returns null + * when either group is below its day floor (so the caller drops the tag) + * or when Welch reports no testable spread AND the means are equal. + */ +function influenceForTag( + days: TaggedDay[], + presentOn: (day: TaggedDay) => boolean, + meta: { + tag: string; + labelKey: string | null; + label: string | null; + categoryKey: string | null; + icon: string | null; + }, +): TagInfluenceRow | null { + const withVals: number[] = []; + const withoutVals: number[] = []; + for (const day of days) { + if (presentOn(day)) withVals.push(day.mean); + else withoutVals.push(day.mean); + } + if ( + withVals.length < INFLUENCE_MIN_PRESENT_DAYS || + withoutVals.length < INFLUENCE_MIN_ABSENT_DAYS + ) { + return null; + } + + const welch = welchTTest(withVals, withoutVals); + + // Deterministic means even when Welch declines (no_variance) — the + // delta is still meaningful, we just cannot attach a p-value, so the + // band falls to `low`. + const withAvg = withVals.reduce((s, v) => s + v, 0) / withVals.length; + const withoutAvg = + withoutVals.reduce((s, v) => s + v, 0) / withoutVals.length; + const delta = withAvg - withoutAvg; + const minGroupDays = Math.min(withVals.length, withoutVals.length); + + // Pooled SD (Cohen's-d denominator) so the board can standardize this raw + // mood-point delta into a unitless effect comparable to a Pearson |r|. + // Unbiased (n−1) per-group variances pooled on (nWith + nWithout − 2) df; + // null when there is no testable spread (both groups perfectly constant). + const nWith = withVals.length; + const nWithout = withoutVals.length; + const varWith = + withVals.reduce((s, v) => s + (v - withAvg) ** 2, 0) / (nWith - 1); + const varWithout = + withoutVals.reduce((s, v) => s + (v - withoutAvg) ** 2, 0) / (nWithout - 1); + const pooledVar = + ((nWith - 1) * varWith + (nWithout - 1) * varWithout) / + (nWith + nWithout - 2); + const pooledSd = pooledVar > 0 ? Math.sqrt(pooledVar) : null; + + const pValue = welch.status === "ok" ? welch.pValue : 1; + // A zero delta with no spread is not an "influence" — drop it so the + // board never shows a 0.0 row. + if (delta === 0) return null; + + return { + tag: meta.tag, + labelKey: meta.labelKey, + label: meta.label, + categoryKey: meta.categoryKey, + icon: meta.icon, + withDays: withVals.length, + withoutDays: withoutVals.length, + withAvg: round(withAvg, 2), + withoutAvg: round(withoutAvg, 2), + delta: round(delta, 2), + pooledSd: pooledSd === null ? null : round(pooledSd, 3), + pValue, + confidence: influenceConfidence(pValue, minGroupDays), + }; +} + +export interface TagInfluence { + /** Flat free-text tag influence rows, ranked by |delta| desc. */ + flat: TagInfluenceRow[]; + /** Structured taxonomy tag influence rows, ranked by |delta| desc. */ + structured: TagInfluenceRow[]; +} + +/** + * Tag "Influence on Mood" (Daylio's flagship): for each frequent flat and + * structured tag, the average daily mood on days the tag is PRESENT vs the + * counterfactual baseline of days it is ABSENT, the delta, and a confidence + * band from a Welch two-sample t-test. + * + * Gating: both groups must clear `INFLUENCE_MIN_PRESENT_DAYS` / + * `INFLUENCE_MIN_ABSENT_DAYS`; rows are ranked by absolute delta and capped + * at `INFLUENCE_MAX_ROWS` per axis. Observational only — the UI applies the + * standing "association, not cause" caption. Sparse tags, single-day tags, + * all-same-value days, and divide-by-zero are all handled cleanly (the row + * is dropped, never NaN). + */ +export function computeTagInfluence( + entries: MoodAggregateEntry[], + now: Date, + windowDays = 90, +): TagInfluence { + const dayMap = collapseToTaggedDays(entries, now, windowDays); + const days = Array.from(dayMap.values()); + + // Need at least one day per side to even attempt — the per-tag floor + // catches the real gate, this just short-circuits an empty history. + if (days.length < INFLUENCE_MIN_PRESENT_DAYS + INFLUENCE_MIN_ABSENT_DAYS) { + return { flat: [], structured: [] }; + } + + // Candidate flat tags: every distinct flat key seen in the window. + const flatKeys = new Set(); + for (const day of days) for (const k of day.flatTags) flatKeys.add(k); + + const flat: TagInfluenceRow[] = []; + for (const key of flatKeys) { + const row = influenceForTag(days, (d) => d.flatTags.has(key), { + tag: key, + labelKey: null, + label: null, + categoryKey: null, + icon: null, + }); + if (row) flat.push(row); + } + + // Candidate structured tags: every distinct structured key + its meta. + const structuredMeta = new Map(); + for (const day of days) { + for (const [key, ref] of day.structuredTags) { + if (!structuredMeta.has(key)) structuredMeta.set(key, ref); + } + } + + const structured: TagInfluenceRow[] = []; + for (const [key, ref] of structuredMeta) { + const row = influenceForTag(days, (d) => d.structuredTags.has(key), { + tag: key, + labelKey: ref.labelKey, + label: ref.label ?? null, + categoryKey: ref.categoryKey, + icon: ref.icon, + }); + if (row) structured.push(row); + } + + const rank = (a: TagInfluenceRow, b: TagInfluenceRow) => + Math.abs(b.delta) - Math.abs(a.delta) || a.tag.localeCompare(b.tag); + + return { + flat: flat.sort(rank).slice(0, INFLUENCE_MAX_ROWS), + structured: structured.sort(rank).slice(0, INFLUENCE_MAX_ROWS), + }; +} diff --git a/src/lib/insights/signals-of-day.ts b/src/lib/insights/signals-of-day.ts new file mode 100644 index 000000000..a3ff81316 --- /dev/null +++ b/src/lib/insights/signals-of-day.ts @@ -0,0 +1,238 @@ +/** + * v1.18.7 — the present-focused "Signals of the day" block for the + * daily briefing: per-metric today-vs-7d/30d deltas, an emerging slope, + * and a recent anomaly, ranked by clinical priority with salient + * signals bubbling first. Every numeric field is pre-computed so the + * model states it rather than re-deriving the comparison. + * + * Extracted verbatim from `features.ts`, which re-exports this module + * so every existing call site keeps importing from there. + */ +import { trendSlope } from "@/lib/analytics/trends"; +import { avgInWindow, stdDev, toDataPoints } from "@/lib/insights/features"; + +/** + * v1.18.7 — one present-focused signal feeding the daily briefing. Every + * numeric field is pre-computed so the model states it rather than + * re-deriving the comparison (which small LLMs do unreliably). + */ +export interface SignalOfDay { + /** Briefing `sourceMetric` discriminator the UI pins an icon + route on. */ + metric: + "bp" | "weight" | "pulse" | "mood" | "sleep" | "resting_hr" | "glucose"; + /** Natural-language label (no enum leak into prose). */ + label: string; + /** Unit string when the metric carries one. */ + unit?: string; + /** Freshest reading (the "now" value the briefing leads with). */ + latest: number; + /** Days since the freshest reading. */ + latestDaysAgo: number; + /** Trailing-7d mean. */ + avg7: number | null; + /** Trailing-30d mean. */ + avg30: number | null; + /** Signed `latest − avg7`, pre-computed. */ + deltaVs7: number | null; + /** Signed `latest − avg30`, pre-computed. */ + deltaVs30: number | null; + /** Normal-swing SD over the trailing-30d window. */ + spread30: number | null; + /** `|latest − avg30| > spread30` — the significance verdict as a boolean. */ + outsideNormalSwing: boolean; + /** Emerging direction over the trailing 30 days (slope sign). */ + emergingTrend: "rising" | "falling" | "flat" | null; + /** A peak / trough inside the last 14 days, when one stands out. */ + recentAnomaly: { + kind: "peak" | "trough"; + value: number; + anomalyDaysAgo: number; + } | null; +} + +/** Compute historical comparison: current 7d avg vs previous 30d avg (days 7-37). */ +export function computeHistoricalComparison( + records: Array<{ value: number; measuredAt: Date }>, + now: number, +): { + current7dAvg: number | null; + previous30dAvg: number | null; + change: number | null; +} { + const current7dAvg = avgInWindow(records, now, 7, 0); + const previous30dAvg = avgInWindow(records, now, 37, 7); + const change = + current7dAvg !== null && previous30dAvg !== null + ? Math.round((current7dAvg - previous30dAvg) * 100) / 100 + : null; + return { current7dAvg, previous30dAvg, change }; +} + +/** + * v1.18.7 — clinical priority order for the signals block. BP / glucose + * lead, then resting HR / pulse, then weight, then mood. + * Lower index = higher priority; the briefing surfaces the top ≤3. + */ +const SIGNAL_PRIORITY: SignalOfDay["metric"][] = [ + "bp", + "glucose", + "resting_hr", + "pulse", + "weight", + "mood", +]; + +/** Round helper local to the signals builder. */ +function r2(v: number): number { + return Math.round(v * 100) / 100; +} + +/** + * Build one signal from a single metric's measurement records. Returns null + * when there is no fresh reading or fewer than three points in 30 days — a + * sparse metric cannot carry an honest "today vs your normal" read. + */ +function buildSignal( + metric: SignalOfDay["metric"], + label: string, + records: Array<{ value: number; measuredAt: Date }>, + now: number, + unit?: string, +): SignalOfDay | null { + if (records.length === 0) return null; + const newest = records[records.length - 1]; + const latestDaysAgo = Math.round( + (now - newest.measuredAt.getTime()) / (24 * 60 * 60 * 1000), + ); + // A reading older than two weeks is not a "signal of the day". + if (latestDaysAgo > 14) return null; + + const win30 = records.filter( + (rec) => rec.measuredAt.getTime() >= now - 30 * 24 * 60 * 60 * 1000, + ); + if (win30.length < 3) return null; + + const avg7 = avgInWindow(records, now, 7); + const avg30 = avgInWindow(records, now, 30); + const spread30 = stdDev(win30.map((rec) => rec.value)); + const deltaVs7 = avg7 !== null ? r2(newest.value - avg7) : null; + const deltaVs30 = avg30 !== null ? r2(newest.value - avg30) : null; + const outsideNormalSwing = + avg30 !== null && spread30 !== null && spread30 > 0 + ? Math.abs(newest.value - avg30) > spread30 + : false; + + const slope = trendSlope(toDataPoints(records), 30, now); + const emergingTrend: SignalOfDay["emergingTrend"] = slope + ? slope.direction === "up" + ? "rising" + : slope.direction === "down" + ? "falling" + : "flat" + : null; + + // Recent anomaly: an extreme inside the last 14 days vs the 30d mean ± 2 SD. + let recentAnomaly: SignalOfDay["recentAnomaly"] = null; + // Track the RAW extreme magnitude — comparing against the already-r2() + // rounded stored value can drop a genuinely larger anomaly. + let bestAbs = 0; + if (avg30 !== null && spread30 !== null && spread30 > 0) { + const recent = records.filter( + (rec) => rec.measuredAt.getTime() >= now - 14 * 24 * 60 * 60 * 1000, + ); + for (const rec of recent) { + const sd = (rec.value - avg30) / spread30; + if (Math.abs(sd) >= 2) { + const abs = Math.abs(rec.value - avg30); + if (recentAnomaly === null || abs > bestAbs) { + bestAbs = abs; + recentAnomaly = { + kind: (sd > 0 ? "peak" : "trough") as "peak" | "trough", + value: r2(rec.value), + anomalyDaysAgo: Math.round( + (now - rec.measuredAt.getTime()) / (24 * 60 * 60 * 1000), + ), + }; + } + } + } + } + + return { + metric, + label, + ...(unit ? { unit } : {}), + latest: r2(newest.value), + latestDaysAgo, + avg7, + avg30, + deltaVs7, + deltaVs30, + spread30, + outsideNormalSwing, + emergingTrend, + recentAnomaly, + }; +} + +/** + * v1.18.7 — assemble the present-focused "Signals of the day" block from the + * in-memory measurement set (no extra DB round-trip). Computes today-vs-7d/30d + * deltas, an emerging slope, and a recent anomaly per salient metric, then + * returns the top ≤3 ranked by clinical priority. Salient signals + * (outside-normal-swing or a recent anomaly) bubble above quiet ones inside + * each priority tier so the briefing leads with what actually moved. + */ +export function computeSignalsOfDay( + byType: (type: string) => Array<{ value: number; measuredAt: Date }>, + now: number, +): SignalOfDay[] { + const candidates: SignalOfDay[] = []; + const push = (s: SignalOfDay | null) => { + if (s) candidates.push(s); + }; + + // Systolic carries the BP signal (the headline number clinicians read first). + push( + buildSignal( + "bp", + "blood pressure (systolic)", + byType("BLOOD_PRESSURE_SYS"), + now, + "mmHg", + ), + ); + // Glucose uses the canonical stored value (the model reads the snapshot, + // not display units); absent data simply produces no signal. + push(buildSignal("glucose", "blood glucose", byType("BLOOD_GLUCOSE"), now)); + push( + buildSignal( + "resting_hr", + "resting heart rate", + byType("RESTING_HEART_RATE"), + now, + "bpm", + ), + ); + push(buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm")); + push(buildSignal("weight", "weight", byType("WEIGHT"), now, "kg")); + // Sleep is stored one row per stage per night, so a raw "latest" point + // would mis-sum; the sleep aggregates carry that signal already. Steps + // ingest as many intraday `stats:`-prefixed samples, so the newest raw row + // is a partial-day fragment, not a daily total — excluded like sleep. + + const priorityIndex = (m: SignalOfDay["metric"]) => { + const idx = SIGNAL_PRIORITY.indexOf(m); + return idx === -1 ? SIGNAL_PRIORITY.length : idx; + }; + const salience = (s: SignalOfDay) => + (s.outsideNormalSwing ? 2 : 0) + (s.recentAnomaly ? 1 : 0); + + return candidates + .sort((a, b) => { + const sal = salience(b) - salience(a); + if (sal !== 0) return sal; + return priorityIndex(a.metric) - priorityIndex(b.metric); + }) + .slice(0, 3); +} diff --git a/src/lib/insights/status-invalidation.ts b/src/lib/insights/status-invalidation.ts new file mode 100644 index 000000000..6bacb2291 --- /dev/null +++ b/src/lib/insights/status-invalidation.ts @@ -0,0 +1,306 @@ +/** + * Per-metric assessment-scope invalidation + refill. + * + * Maps fresh measurement types to the assessment scopes they dirty, + * debounces the ingest-invalidation storm against recently warmed cache + * rows, and enqueues the hash-gated background regenerations — for both + * the ingest path (`invalidateStatusInsightsForTypes`) and the manual + * regenerate path (`enqueueStatusRefillForUser`). + * + * Extracted verbatim from `comprehensive-generate.ts`, which re-exports + * this module so every existing call site (the measurement ingest routes + * and every provider sync) keeps importing from there. + */ +import { prisma } from "@/lib/db"; +import { + enqueueStatusGeneration, + type InsightStatusScope, +} from "@/lib/jobs/insight-status-generate-shared"; +import { normalizeLocale } from "@/lib/insights/status-shared"; +import { isTimeoutStub, statusCacheAction } from "@/lib/insights/status-cache"; +import { + metricIdForMeasurementType, + metricStatusScope, +} from "@/lib/insights/metric-status-registry"; +import { annotate } from "@/lib/logging/context"; +import type { MeasurementType } from "@/generated/prisma/client"; + +/** + * Minimum gap between a scope's last cached (re)generation and the next + * ingest-driven re-enqueue. A scope whose cached assessment was + * (re)generated within this window is left intact on a fresh measurement + * ingest — not re-enqueued. + * + * A constantly-syncing client (Apple Health drips batches every few + * minutes) used to delete + re-enqueue every dirtied scope on every batch, + * and because each delete dropped the cache row the per-(user,scope,locale) + * enqueue singleton (120 s) could not coalesce across batches that arrive + * minutes apart — so STEPS / general / a metric card regenerated several + * times an hour and the assessment felt "regenerated on every visit". This + * window coalesces the storm: a genuinely stale scope (no fresh assessment) + * still refreshes immediately, but a scope refreshed inside the window is + * skipped, so a fresh assessment survives the day's sync drip. + * + * v1.16.8 — first widened from 30 min to 6 h to bound provider spend, but + * a 6 h wall meant same-day data was not narrated same-day: the nightly + * warm at 04:30 restarted the window, so a notable 08:00 reading never + * re-enqueued its scopes and the card showed the pre-reading text until + * the next nightly tick. The window is now a ONE-HOUR minimum gap, and + * the budget role the 6 h wall carried moves to the worker's content-hash + * gate: every ingest that lands past the gap re-enqueues the dirtied + * scopes, the worker's forced run re-gathers the snapshot, and the gate + * (`refreshUnchangedStatusInsight`) turns an unchanged snapshot into a + * timestamp refresh with zero provider cost. So this clock only bounds + * the SQL-gather frequency — at most one worker run per scope per hour + * under a constant sync drip — while provider spend tracks actual data + * change, which is what the gate exists to meter. Comparing the hash + * inline at invalidation time was rejected: it would run the full + * per-scope data gather inside the ingest path, which is exactly the + * work the queue exists to keep off that path. + */ +const INGEST_INVALIDATE_MIN_GAP_MS = 60 * 60 * 1000; + +/** + * The seven per-metric assessment scopes. Each generator persists its + * cached text under `insights.-status.`; the nightly warm + * pass refreshes them through each generator's content-hash gate, and the + * targeted invalidator below re-enqueues only the scopes a fresh + * measurement of a given type actually dirties. + */ +export const PER_STATUS_SCOPES = [ + "blood-pressure", + "pulse", + "weight", + "bmi", + "mood", + "medication-compliance", + "general", +] as const; + +export type PerStatusScope = (typeof PER_STATUS_SCOPES)[number]; + +/** + * Map a measurement type to the assessment scopes a fresh reading of it + * dirties. `general` is the catch-all overview, so every measurement + * type touches it. BMI rides on WEIGHT (it is weight ÷ height²), so a + * new weight reading invalidates both the weight and the BMI card. + */ +function statusScopesForMeasurementType( + type: MeasurementType, +): PerStatusScope[] { + switch (type) { + case "WEIGHT": + return ["weight", "bmi", "general"]; + case "BLOOD_PRESSURE_SYS": + case "BLOOD_PRESSURE_DIA": + return ["blood-pressure", "general"]; + case "PULSE": + case "RESTING_HEART_RATE": + return ["pulse", "general"]; + default: + // Every other tracked metric (body composition, sleep, steps, + // glucose, …) still feeds the general overview assessment. + return ["general"]; + } +} + +/** + * Re-warm the cached per-metric assessments that a batch of fresh + * measurements dirties, so the next mount (or the next nightly warm + * pass) reflects the new data instead of serving the pre-measurement + * text for the rest of the day. + * + * Fire-and-forget from the measurement ingest path — idempotent and + * never a blocker on the user's write. v1.16.8 — the invalidator no + * longer DELETES the cache rows: the worker regenerates each enqueued + * scope with `force: true`, and the generator's content-hash gate + * decides whether the data actually changed. Keeping the row intact + * preserves the stale-while-revalidate read AND lets the gate skip the + * LLM entirely when the dirtying batch turned out to be a re-sync of + * known data. + */ +export async function invalidateStatusInsightsForTypes( + userId: string, + types: Iterable, +): Promise { + // One ordered set of every scope the batch dirties. The seven specialised + // scopes are bare slugs (`weight`, `general`, …); the generic HealthKit + // cards (v1.8.7.1) carry a `metric:` prefix. Both share the cache-key + // shape `insights.-status.`, so a single set covers the + // eviction + the debounce filter + the enqueue uniformly. Only registered, + // data-bearing types contribute a generic scope; the seven specialised + // metrics and any unregistered type resolve to null and are skipped, so + // the constant sync cannot fan out to unwanted scopes. + const scopes = new Set(); + for (const type of types) { + for (const scope of statusScopesForMeasurementType(type)) { + scopes.add(scope); + } + const metricId = metricIdForMeasurementType(type); + if (metricId) { + scopes.add(metricStatusScope(metricId)); + } + } + if (scopes.size === 0) return; + + // v1.8.7 — regenerate only the user's resolved locale, matching the + // read-path (every `*-status` GET serves `normalizeLocale(user.locale)`). + // Warming both locales doubled provider spend on every sync, half of it + // for a language the user never opens. A second locale a client actually + // reads warms lazily through the read-path enqueue on its first miss. + const localeRow = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + const locale = normalizeLocale(localeRow?.locale); + + // v1.9.0 — debounce the ingest-invalidation storm. A constantly-syncing + // client drips batches every few minutes; re-enqueuing every dirtied + // scope on every batch regenerated the same card several times an hour + // (the per-(user,scope,locale) enqueue singleton is only 120 s, so it + // could not coalesce across batches arriving minutes apart). Skip any + // scope whose cached assessment was (re)generated within the minimum + // gap — leave its row intact and do NOT re-enqueue. A genuinely stale + // or missing scope still refreshes immediately, so correctness holds; + // only the redundant churn is removed. Past the gap the enqueue always + // goes through — the worker's content-hash gate decides whether the + // batch actually changed anything (see the constant's doc). + const freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes); + const staleScopes = Array.from(scopes).filter( + (scope) => !freshScopes.has(scope), + ); + if (staleScopes.length === 0) { + annotate({ + action: { name: "insights.status.invalidate.debounced" }, + meta: { skipped: scopes.size, refreshed: 0 }, + }); + return; + } + + // v1.8.7 — regenerate-on-invalidate: enqueue a debounced regenerate for + // each dirtied scope so the cache is re-warmed in the background. The + // enqueue is coalesced per `(user, metric, locale)` via the queue's + // `singletonKey` (120 s window); the debounce above is the second, wider + // coalescing layer that survives across the sync drip. The cache row + // stays in place (stale-while-revalidate keeps the previous assessment + // visible) — the worker forces the generator, whose content-hash gate + // skips the LLM when the batch did not actually change the snapshot. + for (const scope of staleScopes) { + void enqueueStatusGeneration({ userId, metric: scope, locale }); + } + + annotate({ + action: { name: "insights.status.invalidate.debounced" }, + meta: { skipped: freshScopes.size, refreshed: staleScopes.length }, + }); +} + +/** + * v1.16.8 — enqueue a hash-gated refill of every assessment card one user + * actually has: the seven specialised scopes plus the generic + * `metric:` scope of every measurement type with live rows. The + * worker regenerates each enqueued scope with `force: true`, so every + * card skips its same-day cache read, re-gathers its snapshot, and lands + * on the content-hash gate — a card whose data changed regenerates, an + * unchanged card gets a free timestamp refresh. + * + * This is the manual-regenerate path's card story: the POST regenerate + * used to blanket-evict every per-status row, which deleted the hash + * baselines and force-paid ~45 regenerations per click. Enqueuing through + * the gate keeps the baseline rows intact, so a user who noticed a stale + * card gets exactly the changed cards re-narrated — and nothing else. + * Deliberately NOT routed through the ingest debounce: an explicit + * regenerate is a user action, already bounded by the route's hourly + * rate limit and the queue's per-(user,scope,locale) singleton. + * + * Returns the number of scopes enqueued (best-effort — the generic-scope + * discovery read failing still refills the seven specialised cards). + */ +export async function enqueueStatusRefillForUser( + userId: string, + locale: "de" | "en", +): Promise { + const scopes = new Set(PER_STATUS_SCOPES); + try { + // `groupBy` compiles to a server-side `GROUP BY` — Prisma's + // `distinct` dedups in the client AFTER pulling every live row, which + // on a dense multi-year account walks a six-figure row set to answer + // "which types exist?" on the request path. + const rows = await prisma.measurement.groupBy({ + by: ["type"], + where: { userId, deletedAt: null }, + }); + for (const row of rows) { + const metricId = metricIdForMeasurementType(row.type); + if (metricId) scopes.add(metricStatusScope(metricId)); + } + } catch { + // Discovery is best-effort; the specialised scopes still refill. + } + for (const scope of scopes) { + void enqueueStatusGeneration({ userId, metric: scope, locale }); + } + return scopes.size; +} + +/** + * v1.9.0 — return the subset of `scopes` whose cached assessment for + * `locale` was generated within `INGEST_INVALIDATE_MIN_GAP_MS` and is a + * real (non-stub) assessment. Those scopes are skipped by the ingest + * invalidator so a fresh assessment survives the sync drip. + * + * One indexed read per user (a single `findMany` over this user's recent + * status-cache rows, newest-first) answers it for every candidate scope at + * once — cheaper than a per-scope probe and bounded by `take`. A timeout + * stub never counts as fresh (it carries no real assessment), so a scope + * that recently stalled still gets a retry enqueued. + */ +async function findRecentlyWarmedScopes( + userId: string, + locale: "de" | "en", + scopes: ReadonlySet, +): Promise> { + const cutoff = new Date(Date.now() - INGEST_INVALIDATE_MIN_GAP_MS); + // Match exactly the cache actions for the candidate scopes in this locale. + const candidateActions = Array.from(scopes, (scope) => + statusCacheAction(scope, locale), + ); + const actionToScope = new Map(); + for (const scope of scopes) { + actionToScope.set(statusCacheAction(scope, locale), scope); + } + + const rows = await prisma.auditLog.findMany({ + where: { + userId, + action: { in: candidateActions }, + createdAt: { gte: cutoff }, + }, + orderBy: { createdAt: "desc" }, + select: { action: true, details: true }, + }); + + const fresh = new Set(); + for (const row of rows) { + const scope = actionToScope.get(row.action); + if (!scope || fresh.has(scope)) continue; + if (!row.details) continue; + try { + const parsed = JSON.parse(row.details) as { + model?: string; + timeout?: boolean; + text?: string; + }; + // A stub is not a real assessment — let a stalled scope retry. + if (isTimeoutStub(parsed)) continue; + if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { + continue; + } + fresh.add(scope); + } catch { + // Malformed payload — treat as not-fresh so the scope refreshes. + continue; + } + } + return fresh; +} diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts new file mode 100644 index 000000000..6bd22fec3 --- /dev/null +++ b/src/lib/telegram-webhook-handlers.ts @@ -0,0 +1,1238 @@ +import { prisma } from "@/lib/db"; +import { isP2025 } from "@/lib/prisma-errors"; +import { decrypt } from "@/lib/crypto"; +import { + answerTelegramCallbackQuery, + deleteMessage, + sendTelegramMessage, +} from "@/lib/telegram"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { locales, type Locale } from "@/lib/i18n/config"; +import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { + applyCanonicalSlotWrite, + resolveForcedSlotForWrite, + resolveSlotForWriteByBand, +} from "@/lib/medications/scheduling/slot-upsert"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { + consumeForIntake, + restoreForIntake, +} from "@/lib/medications/inventory/consumption"; +import { localHmAsUtc } from "@/lib/tz/local-day"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { + logTelegramMood, + attachTelegramMoodNote, +} from "@/lib/mood/create-from-telegram"; +import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup"; +import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; +import { + isTelegramCapturableType, + logTelegramMeasurement, +} from "@/lib/measurements/create-from-telegram"; + +/** + * Telegram webhook update handlers, split out of + * `src/app/api/telegram/webhook/route.ts` (v1.28.26 file-size split — + * pure code motion, no behaviour change) so the route stays a thin + * shell: rate limit, shared-secret check, JSON parse, dispatch. The + * callback (button-tap) and free-text paths plus their medication / + * mood / measurement helpers live here. The userId is always resolved + * from the linked chat via `findTelegramUser` — never from the payload. + */ + +export interface TelegramUpdate { + update_id: number; + message?: { + message_id?: number; + text?: string; + chat?: { id: number | string }; + reply_to_message?: { + message_id?: number; + }; + }; + callback_query?: { + id: string; + data?: string; + from?: { id?: number | string }; + message?: { + message_id?: number; + chat?: { id: number | string }; + }; + }; +} + +function toChatId(value: number | string | undefined | null): string | null { + if (value === undefined || value === null) return null; + return String(value); +} + +function escapeHtml(input: string): string { + return input + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +/** + * Resolve the bot locale for an existing Telegram user. + * + * Defaults to "de" when User.locale is null — existing bots were set up in + * German and we don't want to silently flip them. Users who have switched + * the UI to English (User.locale = "en") get an English bot too. + */ +function resolveBotLocale(value: string | null | undefined): Locale { + if (value && (locales as readonly string[]).includes(value)) { + return value as Locale; + } + return "de"; +} + +async function cleanupReminderTracking(medicationId: string): Promise { + try { + await prisma.telegramReminderMessage.deleteMany({ + where: { medicationId }, + }); + } catch { + // Best-effort cleanup + } +} + +// v1.19.0 — the auto-delete window dropped from 1 h to ~30 min and moved to +// the shared `scheduleTelegramAutoDelete` helper so the inbound (free-text) +// and outbound (unanswered prompt) paths agree on the window. +async function scheduleAutoDelete( + userId: string, + chatId: string, + messageIds: number[], +): Promise { + await scheduleTelegramAutoDelete(userId, chatId, messageIds); +} + +async function findTelegramUser(chatId: string) { + return prisma.user.findFirst({ + where: { + telegramEnabled: true, + telegramChatId: chatId, + telegramBotToken: { not: null }, + }, + select: { + id: true, + telegramBotToken: true, + locale: true, + // v1.4.39 W-MED — pulled into the select so the post-write + // compliance-rollup hook can anchor the user-day bucket. + timezone: true, + }, + }); +} + +/** + * v1.16.9 — the slot a Telegram action should converge onto. + * + * The reminder context knows its own slot: the dispatcher records every + * reminder message as a `TelegramReminderMessage` row carrying the + * local `date` + `timeOfDay` of the dose it fired for. When the action + * callback carries the chat/message ids, that row names the EXACT slot + * — preferred over band attribution, which would orphan a confirmation + * tapped hours after the reminder (band closed → ad-hoc) even though + * the user is answering a specific dose prompt. + * + * Returns the canonical band anchor (validated against the schedule's + * real slots), or `null` when no reminder row matches / the instant is + * not a real slot — the caller then falls back to band attribution. + */ +async function resolveReminderSlot(input: { + userId: string; + medicationId: string; + userTz: string; + chatId: string; + messageId: number; +}): Promise { + const reminder = await prisma.telegramReminderMessage.findFirst({ + where: { + medicationId: input.medicationId, + chatId: input.chatId, + messageId: input.messageId, + }, + select: { date: true, timeOfDay: true }, + }); + if (!reminder) return null; + const instant = reminderLocalInstant( + reminder.date, + reminder.timeOfDay, + input.userTz, + ); + if (!instant) return null; + // Validate against the medication's real band anchors so a stale or + // malformed reminder row can never park a dose on a non-slot instant. + return resolveForcedSlotForWrite({ + userId: input.userId, + medicationId: input.medicationId, + userTz: input.userTz, + slotInstant: instant, + }); +} + +/** + * Mint the UTC instant of local `timeOfDay` on the local calendar day + * `date` (YYYY-MM-DD) in `tz`. Probes a reference instant a day either + * side of UTC noon so the result is correct in every zone offset, then + * derives the DST-correct instant via `localHmAsUtc`. + */ +function reminderLocalInstant( + date: string, + timeOfDay: string, + tz: string, +): Date | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(timeOfDay)) { + return null; + } + const [hour, minute] = timeOfDay.split(":").map(Number); + const base = new Date(`${date}T12:00:00.000Z`); + if (Number.isNaN(base.getTime())) return null; + for (const shiftDays of [0, -1, 1]) { + const ref = new Date(base.getTime() + shiftDays * 24 * 60 * 60 * 1000); + const parts = wallClockInTz(ref, tz); + const localDay = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String( + parts.day, + ).padStart(2, "0")}`; + if (localDay === date) return localHmAsUtc(ref, tz, hour, minute); + } + return null; +} + +async function markMedicationTaken( + userId: string, + medicationId: string, + idempotencyKey: string, + locale: Locale, + userTz: string | null, + reminderRef?: { chatId: string; messageId: number }, +): Promise<{ ok: boolean; message: string; medicationName?: string }> { + const { t } = getServerTranslator(locale); + const medication = await prisma.medication.findFirst({ + where: { id: medicationId, userId, active: true }, + select: { id: true, name: true }, + }); + if (!medication) { + return { ok: false, message: t("telegram.errorMedicationInactive") }; + } + + const existing = await prisma.medicationIntakeEvent.findFirst({ + where: { userId, medicationId: medication.id, idempotencyKey }, + select: { id: true }, + }); + + let scheduledFor: Date | null = null; + if (!existing) { + const takenAt = new Date(); + const tz = userTz || DEFAULT_TIMEZONE; + + // v1.16.9 — converge onto the slot's canonical row instead of bare- + // creating a second row anchored at `now`. The old shape left the + // worker-minted pending REMINDER row open: the slot later auto-missed + // and compliance punished a dose the user explicitly confirmed. + // Prefer the reminder's own slot (the message names the dose it fired + // for); fall back to band attribution on the tap instant. + let canonicalSlot: Date | null = reminderRef + ? await resolveReminderSlot({ + userId, + medicationId: medication.id, + userTz: tz, + chatId: reminderRef.chatId, + messageId: reminderRef.messageId, + }) + : null; + if (canonicalSlot === null) { + const attribution = await resolveSlotForWriteByBand({ + userId, + medicationId: medication.id, + userTz: tz, + takenAt, + }); + canonicalSlot = attribution.slotInstant; + } + + // The intake write and the snooze reset commit atomically — a + // recorded dose must never leave the medication snoozed-out of its + // own reminders, and a snooze reset must never land without the dose + // (the pre-band shape used a batch transaction here; the slot + // convergence dropped it). The canonical slot write accepts the + // transaction client; its P2002 race-recovery cannot continue inside + // an aborted Postgres transaction, so the rare create race rolls the + // whole write back instead of converging in place — Telegram + // redelivers the non-200 update and the retry finds the raced row. + const written = await prisma.$transaction(async (tx) => { + let slotScheduledFor: Date; + let eventId: string; + // v1.16.10 — only a genuine pending→taken transition may consume + // inventory units; a redelivered button press converging onto an + // already-taken slot row (including a pre-stamp row) must not. + let consumedTransition = true; + if (canonicalSlot) { + const applied = await applyCanonicalSlotWrite({ + client: tx, + userId, + medicationId: medication.id, + canonicalSlot, + takenAt, + skipped: false, + isExplicitTaken: true, + isExplicitSkip: false, + idempotencyKey, + createSource: "REMINDER", + attributionSource: "AUTO", + }); + slotScheduledFor = applied.row.scheduledFor; + eventId = applied.row.id; + consumedTransition = applied.consumedTransition; + } else { + // Ad-hoc / PRN — standalone row under the documented contract + // (`scheduledFor = takenAt`). + const created = await tx.medicationIntakeEvent.create({ + data: { + userId, + medicationId: medication.id, + scheduledFor: takenAt, + takenAt, + skipped: false, + source: "REMINDER", + idempotencyKey, + }, + }); + slotScheduledFor = created.scheduledFor; + eventId = created.id; + } + await tx.medication.update({ + where: { id: medication.id }, + data: { snoozedUntil: null }, + }); + return { slotScheduledFor, eventId, consumedTransition }; + }); + scheduledFor = written.slotScheduledFor; + // v1.16.10 — the button confirmed a take; consume inventory units. + // Runs after the intake transaction committed (the dose record must + // never hinge on the stock write); the stamp keeps a Telegram + // redelivery exactly-once and the transition gate keeps a re-press + // on an already-taken slot from decrementing through a pre-stamp + // row. + if (written.consumedTransition) { + await consumeForIntake({ + client: prisma, + userId, + medicationId: medication.id, + eventId: written.eventId, + intakeAt: takenAt, + }); + } + // The confirmation must reflect on the next read (today feed, card + // pill, compliance) rather than wait out the cache TTL. + invalidateUserMedications(userId, { evict: true }); + } + + // v1.4.39 W-MED — refresh the compliance rollup for the + // affected day so the next read after the cache miss reflects the + // new dose. Only fires when the write landed a row. + if (scheduledFor) { + await recomputeMedicationComplianceForEvent({ + userId, + medicationId: medication.id, + scheduledFor, + tz: userTz, + }); + } + + return { + ok: true, + message: existing + ? t("telegram.alreadyRecorded", { name: medication.name }) + : t("telegram.recordedAsTaken", { name: medication.name }), + medicationName: medication.name, + }; +} + +export async function handleCallback(update: TelegramUpdate) { + const callback = update.callback_query; + if (!callback) return; + + const chatId = + toChatId(callback.message?.chat?.id) ?? toChatId(callback.from?.id); + if (!chatId) return; + + const user = await findTelegramUser(chatId); + if (!user?.telegramBotToken) return; + const botToken = decrypt(user.telegramBotToken); + const locale = resolveBotLocale(user.locale); + const { t } = getServerTranslator(locale); + + const data = callback.data ?? ""; + const messageId = callback.message?.message_id; + + if (data.startsWith("taken:")) { + const medicationId = data.slice("taken:".length).trim(); + if (!medicationId) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.errorInvalidAction"), + ); + return; + } + + const msgId = messageId ?? "na"; + const idempotencyKey = + `telegram:cb:${chatId}:${msgId}:${medicationId}`.slice(0, 128); + + const result = await markMedicationTaken( + user.id, + medicationId, + idempotencyKey, + locale, + user.timezone, + messageId !== undefined ? { chatId, messageId } : undefined, + ); + await answerTelegramCallbackQuery(botToken, callback.id, result.message); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + await cleanupReminderTracking(medicationId); + } else if (data.startsWith("snooze:")) { + // Format: "snooze:{medicationId}:{minutes}" + const parts = data.split(":"); + const medicationId = parts[1]; + const minutes = parseInt(parts[2], 10); + if (!medicationId || !Number.isFinite(minutes)) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.errorInvalidAction"), + ); + return; + } + + const medication = await prisma.medication.findFirst({ + where: { id: medicationId, userId: user.id, active: true }, + select: { id: true, name: true }, + }); + if (!medication) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.errorMedicationNotFound"), + ); + return; + } + + await prisma.medication.update({ + where: { id: medication.id }, + data: { snoozedUntil: new Date(Date.now() + minutes * 60000) }, + }); + + const duration = + minutes <= 60 + ? t("telegram.snoozeOneHour") + : t("telegram.snoozeThreeHours"); + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.snoozedFor", { name: medication.name, duration }), + ); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + await cleanupReminderTracking(medicationId); + } else if (data.startsWith("skip:")) { + const medicationId = data.slice("skip:".length).trim(); + if (!medicationId) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.errorInvalidAction"), + ); + return; + } + + const medication = await prisma.medication.findFirst({ + where: { id: medicationId, userId: user.id, active: true }, + select: { id: true, name: true }, + }); + if (!medication) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.errorMedicationNotFound"), + ); + return; + } + + const msgId = messageId ?? "na"; + const idempotencyKey = + `telegram:skip:${chatId}:${msgId}:${medicationId}`.slice(0, 128); + + const existing = await prisma.medicationIntakeEvent.findFirst({ + where: { userId: user.id, medicationId: medication.id, idempotencyKey }, + select: { id: true }, + }); + + let skippedScheduledFor: Date | null = null; + if (!existing) { + const endOfDay = new Date(); + endOfDay.setHours(23, 59, 59, 999); + const skipMoment = new Date(); + const tz = user.timezone || DEFAULT_TIMEZONE; + + // v1.16.9 — a Telegram skip answers a specific dose prompt, so it + // must converge onto the slot's pending row like a take does. The + // old shape inserted a second row anchored at `now` and left the + // pending REMINDER row open to auto-miss — the user's deliberate + // skip recorded as a miss AND an orphan. Prefer the reminder's own + // slot; fall back to the band the skip moment falls in. + let canonicalSlot: Date | null = + messageId !== undefined + ? await resolveReminderSlot({ + userId: user.id, + medicationId: medication.id, + userTz: tz, + chatId, + messageId, + }) + : null; + if (canonicalSlot === null) { + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: medication.id, + userTz: tz, + takenAt: skipMoment, + }); + canonicalSlot = attribution.slotInstant; + } + + // Atomic like the take path: the skip row and the rest-of-day + // snooze commit together, so a failure can never leave the user + // re-reminded for a recorded skip or silenced without one. Same + // P2002 trade-off as the take path — the rare create race rolls + // back and Telegram's redelivery converges on the raced row. + const writtenSkip = await prisma.$transaction(async (tx) => { + let slotScheduledFor: Date; + let eventId: string; + if (canonicalSlot) { + const applied = await applyCanonicalSlotWrite({ + client: tx, + userId: user.id, + medicationId: medication.id, + canonicalSlot, + takenAt: null, + skipped: true, + isExplicitTaken: false, + isExplicitSkip: true, + idempotencyKey, + createSource: "REMINDER", + }); + slotScheduledFor = applied.row.scheduledFor; + eventId = applied.row.id; + } else { + const created = await tx.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: medication.id, + scheduledFor: skipMoment, + takenAt: null, + skipped: true, + source: "REMINDER", + idempotencyKey, + }, + }); + slotScheduledFor = created.scheduledFor; + eventId = created.id; + } + await tx.medication.update({ + where: { id: medication.id }, + data: { snoozedUntil: endOfDay }, + }); + return { slotScheduledFor, eventId }; + }); + skippedScheduledFor = writtenSkip.slotScheduledFor; + // v1.16.10 — an explicit skip can downgrade a previously-taken + // slot row (last-write-wins); refund whatever its consumption + // stamp recorded. No-op for a never-consumed row. + await restoreForIntake({ + client: prisma, + userId: user.id, + eventId: writtenSkip.eventId, + }); + invalidateUserMedications(user.id, { evict: true }); + } + + // v1.4.39 W-MED — refresh the compliance rollup for the skipped + // event's user-day so the next read sees the (scheduled, skipped) + // counts incremented. + if (skippedScheduledFor) { + await recomputeMedicationComplianceForEvent({ + userId: user.id, + medicationId: medication.id, + scheduledFor: skippedScheduledFor, + tz: user.timezone, + }); + } + + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.skipped", { name: medication.name }), + ); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + await cleanupReminderTracking(medicationId); + } else if (data.startsWith("ack:")) { + const medicationId = data.slice("ack:".length).trim(); + if (!medicationId) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.errorInvalidAction"), + ); + return; + } + + const medication = await prisma.medication.findFirst({ + where: { id: medicationId, userId: user.id }, + select: { id: true, name: true }, + }); + + await answerTelegramCallbackQuery( + botToken, + callback.id, + medication + ? t("telegram.confirmed", { name: medication.name }) + : t("telegram.genericConfirmed"), + ); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + await cleanupReminderTracking(medicationId); + } else if (data.startsWith("add:")) { + // Format: "add:{medicationId}" or "add:{medicationId}:umid:{userMsgId}" + const withoutPrefix = data.slice("add:".length); + const umidIdx = withoutPrefix.indexOf(":umid:"); + const medicationId = + umidIdx >= 0 ? withoutPrefix.slice(0, umidIdx) : withoutPrefix.trim(); + if (!medicationId) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.errorInvalidAction"), + ); + return; + } + + const msgId = messageId ?? "na"; + const idempotencyKey = + `telegram:add:${chatId}:${msgId}:${medicationId}`.slice(0, 128); + + const result = await markMedicationTaken( + user.id, + medicationId, + idempotencyKey, + locale, + user.timezone, + ); + await answerTelegramCallbackQuery(botToken, callback.id, result.message); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + // Also delete the user's /add command message if encoded + const userMsgMatch = data.match(/:umid:(\d+)$/); + if (userMsgMatch) { + const userMsgId = parseInt(userMsgMatch[1], 10); + if (Number.isFinite(userMsgId)) { + await deleteMessage(botToken, chatId, userMsgId).catch(() => {}); + } + } + } else if (data.startsWith("cancel_add")) { + // Delete the bot's selection message + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + // Delete the user's original /add command message if encoded + const userMsgMatch = data.match(/:umid:(\d+)$/); + if (userMsgMatch) { + const userMsgId = parseInt(userMsgMatch[1], 10); + if (Number.isFinite(userMsgId)) { + await deleteMessage(botToken, chatId, userMsgId).catch(() => {}); + } + } + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.cancelled"), + ); + } else if (data.startsWith("mood:")) { + // v1.19.0 — log a mood entry 1–5 from a MOOD_REMINDER prompt tap. + // The score is the ONLY accepted value; the userId is resolved from + // the linked chat above, never from the payload. Confirmation is a + // calm callback toast — no chat echo (restraint). + const score = parseInt(data.slice("mood:".length).trim(), 10); + if (!Number.isInteger(score) || score < 1 || score > 5) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.errorInvalidAction"), + ); + return; + } + + const msgId = messageId ?? "na"; + // Stable per-tap dedup id (Telegram redelivers non-200 updates). The + // mood-create helper upserts on `(userId, source, externalId)`. + const externalId = `telegram:mood:${chatId}:${msgId}:${score}`.slice( + 0, + 120, + ); + + const moodResult = await logTelegramMood({ + userId: user.id, + score, + tz: user.timezone, + externalId, + }); + + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.moodLogged", { score: String(score) }), + ); + // Delete the prompt immediately on a tap; the unanswered-prompt + // scheduled deletion (written at send time) is then a harmless no-op. + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + + // Offer an optional note via force_reply. The reply (when it arrives) + // is correlated back to this mood entry through TelegramPromptContext. + const notePrompt = await sendTelegramMessage( + botToken, + chatId, + t("telegram.moodNotePrompt"), + { + replyMarkup: { + force_reply: true, + input_field_placeholder: t("telegram.buttonMoodNote"), + }, + }, + ); + if (notePrompt.ok && notePrompt.messageId) { + try { + await prisma.telegramPromptContext.create({ + data: { + userId: user.id, + chatId, + promptMsgId: notePrompt.messageId, + kind: "mood_note", + refId: moodResult.moodEntryId, + // Force-reply context lifespan: the same ~30-min window as the + // deletion sweep. A reply after this is treated as expired. + expiresAt: new Date(Date.now() + 30 * 60 * 1000), + }, + }); + } catch { + // Best-effort — without the context row the note simply can't bind. + } + // The note prompt itself self-cleans on the ~30-min sweep whether or + // not the user replies. + await scheduleAutoDelete(user.id, chatId, [notePrompt.messageId]); + } + } else if (data.startsWith("mood_later")) { + // v1.19.0 — restrained "remind me later" for the mood nudge. Clears + // today's dispatch ledger so the next cron tick (inside the user's + // reminder-hour window) re-fires the prompt, mirroring how the + // medication snooze lets the tick re-remind. The prompt is deleted now. + const tz = user.timezone || DEFAULT_TIMEZONE; + const parts = wallClockInTz(new Date(), tz); + const localDate = `${parts.year.toString().padStart(4, "0")}-${parts.month + .toString() + .padStart(2, "0")}-${parts.day.toString().padStart(2, "0")}`; + try { + await prisma.moodReminderDispatch.deleteMany({ + where: { userId: user.id, date: localDate }, + }); + } catch { + // Best-effort — a failure just means the day's nudge isn't re-armed. + } + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.remindLater", { duration: t("telegram.snoozeOneHour") }), + ); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + } else if (data.startsWith("measure_done:")) { + // v1.19.0 — mark a Vorsorge/measurement reminder done; satisfy the + // cadence via the shared primitive. + const reminderId = data.slice("measure_done:".length).trim(); + const reminder = await prisma.measurementReminder.findFirst({ + where: { id: reminderId, userId: user.id, deletedAt: null }, + select: { + id: true, + // v1.19.2 — the target metric; drives the optional numeric capture. + measurementType: true, + intervalDays: true, + rrule: true, + anchorDate: true, + notifyHour: true, + lastSatisfiedAt: true, + createdAt: true, + }, + }); + if (!reminder) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.measurementNotFound"), + ); + return; + } + const tz = user.timezone || DEFAULT_TIMEZONE; + await satisfyReminder(prisma, reminder, tz, new Date()); + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.measurementDone"), + ); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + + // v1.19.2 — optional numeric value capture. When the reminder names a + // single-value metric (BP and free-text Vorsorge are excluded), offer a + // force-reply prompt; a numeric reply is captured as a Measurement + // (source=TELEGRAM) in `handleTextMessage`. The reply binds to THIS + // reminder + user through a TelegramPromptContext keyed on the prompt + // message id, the SAME strict chat/message binding the mood-note path + // uses — the userId is never read from the inbound payload. Optional and + // self-cleaning: ignoring the prompt leaves the cadence already + // satisfied, and the prompt is swept on the ~30-min auto-delete. + if (isTelegramCapturableType(reminder.measurementType)) { + const valuePrompt = await sendTelegramMessage( + botToken, + chatId, + t("telegram.measureValuePrompt"), + { + replyMarkup: { + force_reply: true, + input_field_placeholder: t("telegram.buttonMeasureValue"), + }, + }, + ); + if (valuePrompt.ok && valuePrompt.messageId) { + try { + await prisma.telegramPromptContext.create({ + data: { + userId: user.id, + chatId, + promptMsgId: valuePrompt.messageId, + kind: "measure_value", + refId: reminder.id, + // Same ~30-min window as the deletion sweep; a later reply is + // treated as expired. + expiresAt: new Date(Date.now() + 30 * 60 * 1000), + }, + }); + } catch { + // Best-effort — without the context row the value simply can't bind. + } + await scheduleAutoDelete(user.id, chatId, [valuePrompt.messageId]); + } + } + } else if (data.startsWith("measure_later:")) { + // v1.19.0 — postpone a measurement reminder by N minutes (one-shot + // nudge). Pushes `nextDueAt` forward; the cron re-fires once due AND in + // the user's notify-hour window. Mirrors the medication snooze. + const parts = data.split(":"); + const reminderId = parts[1]; + const minutes = parseInt(parts[2] ?? "180", 10); + if (!reminderId || !Number.isFinite(minutes)) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.errorInvalidAction"), + ); + return; + } + const reminder = await prisma.measurementReminder.findFirst({ + where: { id: reminderId, userId: user.id, deletedAt: null }, + select: { id: true }, + }); + if (!reminder) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.measurementNotFound"), + ); + return; + } + await prisma.measurementReminder.update({ + where: { id: reminder.id }, + data: { nextDueAt: new Date(Date.now() + minutes * 60000) }, + }); + const duration = + minutes <= 60 + ? t("telegram.snoozeOneHour") + : t("telegram.snoozeThreeHours"); + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.remindLater", { duration }), + ); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + } else { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.errorUnknownAction"), + ); + } +} + +export async function handleTextMessage(update: TelegramUpdate) { + const message = update.message; + const text = message?.text?.trim(); + const chatId = toChatId(message?.chat?.id); + if (!text || !chatId) return; + + const user = await findTelegramUser(chatId); + if (!user?.telegramBotToken) return; + const botToken = decrypt(user.telegramBotToken); + const locale = resolveBotLocale(user.locale); + const { t } = getServerTranslator(locale); + + // v1.19.0 — force-reply note capture. When this message replies to a + // tracked mood-note prompt, attach the text to the just-logged mood entry + // and clean up both messages. The context row is keyed on + // `(chatId, promptMsgId)` and scoped to THIS user, so a reply can only + // ever attach to that user's own entry. + const replyToId = message?.reply_to_message?.message_id; + if (replyToId !== undefined) { + const context = await prisma.telegramPromptContext.findUnique({ + where: { + chatId_promptMsgId: { chatId, promptMsgId: replyToId }, + }, + select: { + id: true, + userId: true, + kind: true, + refId: true, + expiresAt: true, + }, + }); + if ( + context && + context.userId === user.id && + (context.kind === "mood_note" || context.kind === "measure_value") + ) { + // The delete IS the replay guard: consuming the context row is what + // stops a redelivered reply from being applied to the entry twice. Make + // it authoritative — if the delete fails for any reason OTHER than the + // row already being gone (P2025), do NOT apply the reply; abort and let + // Telegram redeliver against a still-present row. P2025 means the row + // was already consumed, which is the guard succeeding, so proceed. + try { + await prisma.telegramPromptContext.delete({ + where: { id: context.id }, + }); + } catch (err) { + if (!isP2025(err)) return; + } + const userMsgId = message?.message_id; + if (context.expiresAt.getTime() < Date.now()) { + const resp = await sendTelegramMessage( + botToken, + chatId, + context.kind === "measure_value" + ? t("telegram.measureValueExpired") + : t("telegram.moodNoteExpired"), + ); + const toDelete = [userMsgId, replyToId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + + if (context.kind === "measure_value") { + // v1.19.2 — numeric value capture for a measurement reminder. The + // reminder named on the context row carries the expected metric; the + // userId is the chat-resolved one (never the payload). A non-numeric + // / out-of-range reply gets a calm hint, not a write. The reply, the + // prompt, and the confirmation all self-clean on the ~30-min sweep. + const reminder = await prisma.measurementReminder.findFirst({ + where: { id: context.refId, userId: user.id, deletedAt: null }, + select: { measurementType: true }, + }); + let confirmation: string; + if (!isTelegramCapturableType(reminder?.measurementType)) { + confirmation = t("telegram.measurementNotFound"); + } else { + const externalId = `telegram:measure:${chatId}:${replyToId}`.slice( + 0, + 120, + ); + const result = await logTelegramMeasurement({ + userId: user.id, + type: reminder.measurementType, + rawText: text, + tz: user.timezone, + externalId, + }); + confirmation = + result.status === "ok" + ? t("telegram.measureValueSaved") + : result.status === "out_of_range" + ? t("telegram.measureValueOutOfRange") + : t("telegram.measureValueInvalid"); + } + const resp = await sendTelegramMessage(botToken, chatId, confirmation); + const toDelete = [userMsgId, replyToId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + + const attached = await attachTelegramMoodNote({ + userId: user.id, + moodEntryId: context.refId, + note: text, + }); + const resp = await sendTelegramMessage( + botToken, + chatId, + attached + ? t("telegram.moodNoteSaved") + : t("telegram.errorInvalidAction"), + ); + // Self-clean the note, the prompt, and the confirmation in ~30 min. + const toDelete = [userMsgId, replyToId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + } + + // "Help" / start: accept English + German keyword aliases independent of locale + if ( + /^\/help\b/i.test(text) || + /^\/start\b/i.test(text) || + /^hilfe$/i.test(text) || + /^help$/i.test(text) + ) { + const userMsgId = message?.message_id; + const resp = await sendTelegramMessage( + botToken, + chatId, + `${t("telegram.helpHeader")}\n\n${t("telegram.helpBody")}`, + ); + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + + if (/^\/add\b/i.test(text)) { + const userMsgId = message?.message_id; + const meds = await prisma.medication.findMany({ + where: { userId: user.id, active: true }, + select: { id: true, name: true, dose: true }, + orderBy: { name: "asc" }, + }); + + if (meds.length === 0) { + const resp = await sendTelegramMessage( + botToken, + chatId, + t("telegram.noActiveMedications"), + ); + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + + if (meds.length === 1) { + const idempotencyKey = + `telegram:add:${update.update_id}:${meds[0].id}`.slice(0, 128); + const result = await markMedicationTaken( + user.id, + meds[0].id, + idempotencyKey, + locale, + user.timezone, + ); + const resp = await sendTelegramMessage( + botToken, + chatId, + result.ok + ? `${escapeHtml(result.message)}` + : `${escapeHtml(result.message)}`, + ); + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + + // Encode user's message ID in callback data so cancel/add can delete it + const umidSuffix = userMsgId ? `:umid:${userMsgId}` : ""; + const keyboard = { + inline_keyboard: [ + ...meds.map((med) => [ + { + text: `${med.name} (${med.dose})`, + callback_data: `add:${med.id}${umidSuffix}`, + }, + ]), + [ + { + text: t("telegram.cancelButton"), + callback_data: `cancel_add${umidSuffix}`, + }, + ], + ], + }; + const resp = await sendTelegramMessage( + botToken, + chatId, + t("telegram.whichMedication"), + { parseMode: "HTML", replyMarkup: keyboard }, + ); + // Fallback auto-delete if user never interacts with the selection + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + + // Greeting responses (kept locale-independent) + const greetings = ["hi", "hallo", "hey", "moin", "hello"]; + const lowerText = text.toLowerCase(); + const matchedGreeting = greetings.find((g) => lowerText === g); + if (matchedGreeting) { + const userMsgId = message?.message_id; + const reply = text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); + const resp = await sendTelegramMessage(botToken, chatId, `${reply}! 👋`); + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + + // Accept both "genommen " (DE) and "taken " (EN) regardless of + // the user's UI locale — keeps backwards compatibility. + const intakeKeyword = /^(?:genommen|taken)\b/i; + if (!intakeKeyword.test(text)) return; + + const userMsgId = message?.message_id; + const nameInput = text.replace(intakeKeyword, "").trim(); + + let medicationId: string | null = null; + if (nameInput) { + const med = await prisma.medication.findFirst({ + where: { + userId: user.id, + active: true, + name: { equals: nameInput, mode: "insensitive" }, + }, + select: { id: true }, + }); + medicationId = med?.id ?? null; + } else { + const meds = await prisma.medication.findMany({ + where: { userId: user.id, active: true }, + select: { id: true, name: true }, + orderBy: { name: "asc" }, + take: 2, + }); + if (meds.length === 1) { + medicationId = meds[0].id; + } else { + const resp = await sendTelegramMessage( + botToken, + chatId, + t("telegram.askMedicationName"), + ); + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + } + + if (!medicationId) { + const resp = await sendTelegramMessage( + botToken, + chatId, + t("telegram.medicationNotFoundExact"), + ); + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + + const idempotencyKey = + `telegram:text:${update.update_id}:${medicationId}`.slice(0, 128); + const result = await markMedicationTaken( + user.id, + medicationId, + idempotencyKey, + locale, + user.timezone, + ); + const resp = await sendTelegramMessage( + botToken, + chatId, + result.ok + ? `✅ ${escapeHtml(result.message)}` + : `⚠️ ${escapeHtml(result.message)}`, + ); + const toDelete = [userMsgId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } +}