From d448e6c9c61f2ce627bd7e272cb40e372b9e30fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:12:59 +0200 Subject: [PATCH] refactor(insights): move the medication card onto the pipeline with its own note codec --- .../medication-compliance-status/route.ts | 6 +- .../insights/medication-compliance-status.ts | 745 ++++++++---------- 2 files changed, 338 insertions(+), 413 deletions(-) diff --git a/src/app/api/insights/medication-compliance-status/route.ts b/src/app/api/insights/medication-compliance-status/route.ts index 38d8bade3..2be9b3368 100644 --- a/src/app/api/insights/medication-compliance-status/route.ts +++ b/src/app/api/insights/medication-compliance-status/route.ts @@ -1,9 +1,7 @@ import { NextRequest } from "next/server"; import { apiSuccess } from "@/lib/api-response"; -import { - generateMedicationComplianceStatusForUser, - resolveMedicationComplianceStatusLocale, -} from "@/lib/insights/medication-compliance-status"; +import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; +import { normalizeLocale as resolveMedicationComplianceStatusLocale } from "@/lib/insights/status-shared"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index e944508e1..a71719ec9 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -3,8 +3,6 @@ import { getMedicationComplianceSystemPrompt, getMedicationComplianceUserPrompt, } from "@/lib/ai/prompts/medication-compliance"; -import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; -import type { Locale } from "@/lib/i18n/config"; import { buildComplianceMedicationContext, COURSES_COMPLIANCE_SELECT, @@ -14,44 +12,37 @@ import { lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; -import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver"; import { getMedicationCategories } from "@/lib/medication-category"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; import { getNoKeyMedicationComplianceStatusText } from "@/lib/insights/no-key-fallbacks"; -import { - formatPreviousContextForPrompt, - getPreviousInsightContext, -} from "@/lib/insights/memory"; -import { - buildAssessmentContextBlock, - pickVarietyLead, -} from "@/lib/insights/assessment-context"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series"; -import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage"; import { buildMetricSignal } from "@/lib/insights/metric-signal"; -import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series"; import { - type SupportedLocale, - finalizeStatusSummary, - normalizeLocale, normalizeSummaryText, round, + type SupportedLocale, } from "@/lib/insights/status-shared"; import { readFreshStatusItems, refreshUnchangedStatusItems, - resolveReadOnlyStatusMiss, - statusCacheAction, writeStatusNote, } from "@/lib/insights/status-cache"; -import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { runPreparedStatusCard, type PreparedStatusCard, } from "@/lib/insights/status-card-generation"; -import { annotate } from "@/lib/logging/context"; +import { + coverageSpan, + prepareStatusCard, + type ServedBuild, + type StatusCardBuild, + type StatusCardContext, + type StatusCardOptions, + type StatusCardPlugin, + type StatusNoteCodec, +} from "@/lib/insights/status-pipeline"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; // 360 daily days + 24 monthly windows ≈ 1080 days of intake history. @@ -80,7 +71,7 @@ function parseMedicationItems(items: unknown[]): MedicationSummaryItem[] { * status result. It flows through the shared `PreparedStatusCard` union via * the open index signature on `StatusCardResult`. */ -interface MedicationComplianceResult { +type MedicationComplianceResult = { hasProvider: boolean; summary: string | null; medications: MedicationSummaryItem[]; @@ -88,7 +79,7 @@ interface MedicationComplianceResult { updatedAt: string | null; preparing?: boolean; revalidating?: boolean; -} +}; /** * Public entry — unchanged signature. Prepares the card then runs ONE @@ -97,11 +88,7 @@ interface MedicationComplianceResult { */ export async function generateMedicationComplianceStatusForUser( userId: string, - options?: { - locale?: string | null; - force?: boolean; - readOnly?: boolean; - }, + options?: StatusCardOptions, ): Promise { const prepared = await prepareMedicationComplianceStatusForUser( userId, @@ -112,114 +99,157 @@ export async function generateMedicationComplianceStatusForUser( } /** - * v1.18.7 — everything up to (not including) the provider call. - * Carries the richer `{ summary, medications }` result through the shared - * prepare/run/finalize contract. See `status-card-generation.ts`. + * Everything up to (not including) the provider call, through the shared + * status pipeline (`status-pipeline.ts`). Carries the richer + * `{ summary, medications }` result through its own note codec. */ -export async function prepareMedicationComplianceStatusForUser( +export function prepareMedicationComplianceStatusForUser( userId: string, - options?: { - locale?: string | null; - force?: boolean; - /** v1.8.3 — read-only navigation path; see weight-status for the rationale. */ - readOnly?: boolean; - }, + options?: StatusCardOptions, ): Promise { - const locale = normalizeLocale(options?.locale); - const force = options?.force === true; - const readOnly = options?.readOnly === true; - const cacheAction = statusCacheAction("medication-compliance", locale); - // v1.30.3 — resolve the user's own tz BEFORE the day-key so the - // cache rolls over at the user's local midnight, not Berlin's. Has to - // happen ahead of the cache read below (the earliest possible return - // path), not just the later day-bucketing reads. - const userTz = await resolveUserTimezone(userId); - const todayKey = userDayKey(new Date(), userTz); + return prepareStatusCard(MEDICATION_COMPLIANCE_STATUS_CARD, userId, options); +} - // This card stores per-medication lines next to its summary, so it reads - // its note through the items variant of the shared reader. - if (!force) { +/** + * This card stores per-medication lines next to its summary, so it reads and + * re-dates its note through the items variants of the shared readers, and + * its result carries the lines as `medications`. + */ +const medicationComplianceCodec: StatusNoteCodec< + MedicationComplianceResult, + MedicationSummaryItem[] +> = { + async readFresh(ctx) { + if (ctx.force) return null; const cached = await readFreshStatusItems({ - userId, - cacheAction, - todayKey, + userId: ctx.userId, + cacheAction: ctx.cacheAction, + todayKey: ctx.todayKey, }); - if (cached && Array.isArray(cached.items)) { - return { - phase: "served", - result: { - hasProvider: true, - summary: cached.text, - medications: parseMedicationItems(cached.items), - cached: true, - updatedAt: cached.updatedAt, - }, - }; - } - } - - // v1.8.3 — read-only navigation path: never block on the provider. - // Enqueue generation out of band and return preparing / no-provider. - if (readOnly) { - const outcome = await resolveReadOnlyStatusMiss({ - userId, - metric: "medication-compliance", - locale, + if (!cached || !Array.isArray(cached.items)) return null; + return { + hasProvider: true, + summary: cached.text, + medications: parseMedicationItems(cached.items), + cached: true, + updatedAt: cached.updatedAt, + }; + }, + async refreshUnchanged(ctx, snapshotHash) { + const unchanged = await refreshUnchangedStatusItems({ + userId: ctx.userId, + cacheAction: ctx.cacheAction, + todayKey: ctx.todayKey, + snapshotHash, }); + if (!unchanged || !Array.isArray(unchanged.items)) return null; + return { + hasProvider: true, + summary: unchanged.text, + medications: parseMedicationItems(unchanged.items), + cached: true, + updatedAt: unchanged.updatedAt, + }; + }, + readOnly(outcome, unavailableText) { // Unavailable for any reason: the deterministic line, no enqueue. // `hasProvider` is provider presence only. if (outcome.kind === "unavailable") { return { - phase: "served", - result: { - hasProvider: outcome.hasProvider, - summary: getNoKeyMedicationComplianceStatusText(locale), - medications: [], - cached: true, - updatedAt: null, - }, + hasProvider: outcome.hasProvider, + summary: unavailableText, + medications: [], + cached: true, + updatedAt: null, }; } - // v1.8.7 — stale-while-revalidate: serve the last good narrative (if - // any) while the worker re-warms; only show the empty preparing - // skeleton when none was ever produced. + // Stale-while-revalidate: serve the last good narrative (if any) while + // the worker re-warms; only show the empty preparing skeleton when none + // was ever produced. return { - phase: "served", - result: { - hasProvider: true, - summary: outcome.lastGood?.text ?? null, - medications: [], - cached: outcome.lastGood !== null, - updatedAt: outcome.lastGood?.updatedAt ?? null, - preparing: outcome.lastGood === null, - revalidating: outcome.revalidating, - }, + hasProvider: true, + summary: outcome.lastGood?.text ?? null, + medications: [], + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, }; - } + }, + noProvider(text) { + return { + hasProvider: false, + summary: text, + medications: [], + cached: true, + updatedAt: null, + }; + }, + fallback(ctx, reason, stubText, medicationSummaries) { + // Transient miss or a withheld reply — write the short-TTL negative stub + // (so the read-only route does not re-enqueue while the provider is + // degraded) and serve the fallback for this render without persisting an + // assessment. A withheld reply still renders the per-medication rows. + returnTimeoutFallback({ + cacheAction: ctx.cacheAction, + reason, + userId: ctx.userId, + todayKey: ctx.todayKey, + stubText, + }); + return { + hasProvider: true, + summary: stubText, + medications: reason === "screened" ? medicationSummaries : [], + cached: true, + updatedAt: null, + }; + }, + async persist(ctx, summary, hashes, medicationSummaries) { + // The compliance prompt returns a single `{ summary }` envelope — it + // does not emit per-medication text. The per-medication cards carry a + // placeholder so the UI surfaces a row per active medication; the + // overall `summary` is the model-authored assessment. + const generatedAt = await writeStatusNote({ + userId: ctx.userId, + cacheAction: ctx.cacheAction, + todayKey: ctx.todayKey, + text: summary, + items: medicationSummaries, + snapshotHash: hashes.snapshotHash, + }); + return { + hasProvider: true, + summary, + medications: medicationSummaries, + cached: false, + updatedAt: generatedAt.toISOString(), + }; + }, +}; - const medicationRows = await prisma.medication.findMany({ - // v1.16.11 — as-needed (PRN) medications never surface a compliance - // rate (no expected doses). - where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE }, - // v1.15.20 — schedules through the shared compliance select so the - // configured per-dose windows reach this surface like every other. - include: { - schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, - // v1.16.3 — archived schedule eras for era-aware compliance. - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - // v1.25 — pause eras so paused days drop out of the denominator. - pauseEras: { select: { pausedAt: true, resumedAt: true } }, - // v1.40 (#1024) — the courses, so a gap between two expects nothing. - courses: COURSES_COMPLIANCE_SELECT, - }, - orderBy: { name: "asc" }, - }); +type ComplianceMedication = Awaited< + ReturnType +>[number]; +type ComplianceIntakeEvent = { + medicationId: string; + scheduledFor: Date; + takenAt: Date | null; + skipped: boolean; +}; - // A medication with no schedule expects no dose, and `calculateCompliance` - // answers 100 % for that case. The `asNeeded: false` filter above catches - // only the PRN arm; the empty schedule is reachable on a scheduled - // medication and would otherwise report perfect adherence. - const medications = medicationRows.filter(expectsDoses); +/** + * The adherence snapshot: one block per active, scheduled medication with + * its compliance, streak and per-day series, and the overall averages. + */ +async function buildMedicationComplianceSnapshot( + ctx: StatusCardContext, +): Promise< + | StatusCardBuild + | ServedBuild +> { + const { userId, locale, userTz, todayKey } = ctx; + const medications = await readComplianceMedications(userId); if (medications.length === 0) { return { @@ -269,89 +299,16 @@ export async function prepareMedicationComplianceStatusForUser( // the canonical engine on the user's own tz. `userTz` was already // resolved above for the cache day-key. - const medicationSnapshots = medications.map((medication) => { - const events = medicationEvents.filter( - (event) => event.medicationId === medication.id, - ); - - const medicationContext = buildComplianceMedicationContext( + const medicationSnapshots = medications.map((medication) => + medicationSnapshot({ medication, - lastNonSkippedTakenAt(events), - userTz, - ); - // v1.18.0 — one shared cadence-aware expansion pass yields BOTH the - // canonical compliance7 / compliance30 (the values shown in this same - // snapshot) AND the unified dose-history ledger the per-day series is - // built from, so the series can never disagree with compliance30. - const bundle = buildMedicationComplianceBundle( - events, - medication.schedules, - medicationContext, + medicationEvents, + categoryMap, now, - ); - const compliance7 = bundle.compliance7; - const compliance30 = bundle.compliance30; - - // v1.18.0 — the per-day series honours the medication's cadence - // (daysOfWeek + intervalWeeks + rolling / RRULE / cyclic): only days the - // schedule actually expected a dose produce a point, and each day's rate - // uses the SAME taken / (taken + missed) tally as compliance30. The old - // path divided every event-day by `schedules.length`, treating every - // calendar day as fully dose-due — which made a weekly-cadence med's - // per-day series contradict its own compliance30. Reusing the canonical - // ledger fixes that by construction. - const perDayRecords = dailyComplianceRatesFromLedger( - bundle.ledgerRows, userTz, - ).map((day) => ({ - measuredAt: day.date, - value: round(day.rate, 1), - })); - - // `applyPayloadBudget` gives the latest-day focus; the compact - // graded series is what reaches the prompt. - const dailyBudgeted = applyPayloadBudget(perDayRecords, { - now, - tz: userTz, - }); - const dailySeries = buildGradedSeriesFromPoints(perDayRecords, now, userTz); - const latestDay = dailyBudgeted.daily[0] ?? null; - - // v1.18.10 — the finished recent-vs-baseline adherence comparison - // + normal-swing verdict, computed from the canonical per-day rate series - // so the model states the trend rather than deriving it. The compliance % - // itself stays server-computed (compliance7/30 above); this adds only the - // trend signal. Adherence reads higher-better; the band is 0–100%. - const signal = buildMetricSignal({ - metric: - locale === "en" - ? `adherence for ${sanitizeForPrompt(medication.name)}` - : `Einnahmetreue für ${sanitizeForPrompt(medication.name)}`, - unit: "%", - direction: "higher-better", - graded: dailySeries, - // Recency is surfaced separately via dataCoverage.newestMeasurementDaysAgo - // (computed after this map); the per-med signal leaves it null. - newestDaysAgo: null, - }); - - return { - medicationId: medication.id, - name: sanitizeForPrompt(medication.name), - dose: sanitizeForPrompt(medication.dose, 50), - category: categoryMap[medication.id] ?? "OTHER", - schedulesPerDay: medication.schedules.length, - compliance7: compliance7.rate, - compliance30: compliance30.rate, - streak: compliance7.streak, - taken7: compliance7.taken, - skipped7: compliance7.skipped, - missed7: compliance7.missed, - ...(signal ? { signal } : {}), - dailySeries, - latestDay, - }; - }); + locale, + }), + ); const avgCompliance30 = medicationSnapshots.length > 0 @@ -364,22 +321,13 @@ export async function prepareMedicationComplianceStatusForUser( ) : null; - const oldestEvent = - medicationEvents.length > 0 ? medicationEvents[0].scheduledFor : null; - const newestEvent = - medicationEvents.length > 0 - ? medicationEvents[medicationEvents.length - 1].scheduledFor - : null; - const totalSpanDays = - oldestEvent && newestEvent - ? Math.round( - (newestEvent.getTime() - oldestEvent.getTime()) / - (24 * 60 * 60 * 1000), - ) - : 0; - const newestMeasurementDaysAgo = newestEvent - ? Math.round((Date.now() - newestEvent.getTime()) / (24 * 60 * 60 * 1000)) - : null; + const { totalSpanDays, newestDaysAgo: newestMeasurementDaysAgo } = + coverageSpan( + medicationEvents.length > 0 ? medicationEvents[0].scheduledFor : null, + medicationEvents.length > 0 + ? medicationEvents[medicationEvents.length - 1].scheduledFor + : null, + ); const snapshot = { locale, @@ -404,206 +352,185 @@ export async function prepareMedicationComplianceStatusForUser( medications: medicationSnapshots, }; - const shed = degradeStatusSnapshotToBudget( - snapshot as unknown as Record, - { maxChars: await statusSnapshotCharsForRecord(userId) }, - ); - const snapshotJson = JSON.stringify(snapshot); - - annotate({ - action: { name: cacheAction }, - meta: { - payload_size_bytes: snapshotJson.length, - ...(shed.length > 0 ? { snapshot_shed: shed } : {}), - }, - }); - - // Content-hash gate (v1.16.8): when the snapshot is unchanged since the - // last real assessment, refresh the cache timestamp and skip the LLM. - // This card stores per-medication lines next to the summary, so it uses - // the items variant of the shared gate. - const snapshotHash = hashInsightSnapshot(snapshot); - const unchanged = await refreshUnchangedStatusItems({ - userId, - cacheAction, - todayKey, - snapshotHash, - }); - if (unchanged && Array.isArray(unchanged.items)) { - return { - phase: "served", - result: { - hasProvider: true, - summary: unchanged.text, - medications: parseMedicationItems(unchanged.items), - cached: true, - updatedAt: unchanged.updatedAt, - }, - }; - } - - const previousContext = await getPreviousInsightContext( - userId, - "medication-compliance-status", - locale, - 12, - ); - const previousContextBlock = formatPreviousContextForPrompt( - previousContext, - locale, - ); - - // v1.12.7 — diversity / anti-repetition block (see blood-pressure-status). - // Adherence spans multiple medications with no single card-level graded - // mean to band, so the steady-run signal is left to the previous-context - // comparison (repeatCount 0) and there is no measurement discovery channel - // (relations empty); the variety lead + data-strength carry the rotation. - const varietyLead = pickVarietyLead( - userId, - "medication-compliance", - todayKey, - ); - const assessmentContextBlock = buildAssessmentContextBlock( - { - varietyLead, - dataStrength: { + return { + snapshot, + finish: () => ({ + // Adherence spans multiple medications with no single card-level graded + // mean to band, so the steady-run signal is left to the previous-context + // comparison (repeatCount 0) and there is no measurement discovery + // channel (relations empty); the variety lead + data-strength carry the + // rotation. + context: { + repeatCount: 0, points: medicationEvents.length, newestDaysAgo: newestMeasurementDaysAgo, + relationsFor: null, }, - repeatCount: 0, - relations: [], - }, - locale, - ); - - // The per-medication placeholder rows the UI surfaces alongside the - // model-authored overall `summary`. Computed here so both `finalize` - // (success) reuses them; the model's compliance prompt returns only the - // single `{ summary }` envelope. - const medicationSummaries: MedicationSummaryItem[] = medicationSnapshots.map( - (medication) => ({ - medicationId: medication.medicationId, - text: normalizeSummaryText( - locale === "de" - ? `${medication.name}: Es liegen noch nicht genügend konsistente Detaildaten für eine belastbare Kurzbewertung vor.` - : `${medication.name}: There is currently not enough consistent detail data for a robust short assessment.`, - ), - }), - ); - - // The provider call is deferred to the caller — single-card path runs ONE - // completion; the batch path folds this prompt into one shared call. - return { - phase: "pending", - metric: "medication-compliance", - userId, - cacheAction, - systemPrompt: getMedicationComplianceSystemPrompt(locale), - userPrompt: getMedicationComplianceUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - assessmentContextBlock, - // v1.28.40 — rotating opener hint, per (user, metric, day). - openerArchetypeHint( - `${userId}:medication-compliance:${todayKey}`, - locale as Locale, - ), - ), - snapshotHash, - // v1.12.7 — match the archetype cards' 0.45. - temperature: 0.45, - noProvider: { - hasProvider: false, - summary: getNoKeyMedicationComplianceStatusText(locale), - medications: [], - cached: true, - updatedAt: null, - }, - timeout: (reason) => { - // Transient miss — write the short-TTL negative stub (so the read-only - // route does not re-enqueue while the provider is degraded) and serve - // the fallback for this render without persisting an assessment. - returnTimeoutFallback({ - cacheAction, - reason, - userId, - todayKey, - stubText: getNoKeyMedicationComplianceStatusText(locale), - }); - return { - hasProvider: true, - summary: getNoKeyMedicationComplianceStatusText(locale), - medications: [], - cached: true, - updatedAt: null, - }; - }, - finalize: async (outcome) => { - // The compliance prompt returns a single `{ summary }` envelope — it - // does not emit per-medication text. The per-medication cards carry a - // placeholder so the UI surfaces a row per active medication; the - // overall `summary` is the model-authored assessment. - // - // v1.32.20 — route the completion through the shared status chokepoint, - // so this card enforces the exact contracts its seven siblings do rather - // than only withholding unparseable envelopes. `finalizeStatusSummary` - // parses the `{ summary }` envelope, scrubs chart tokens + whitespace, - // then SCREENS the prose against `INSIGHTS_CONTRACTS` (dose / risk / - // causal). This is the highest-leverage surface for the outbound screen — - // a medication card is exactly where a dose-change imperative or a - // fabricated figure lands. WITHHOLD on any block or unparseable envelope: - // serve the deterministic no-key line, write a short negative stub, and - // persist no model text. The per-medication rows still render. - const finalized = finalizeStatusSummary(outcome.content, locale); - if (!finalized.ok) { - annotate({ - action: { name: "insights.status.outbound_blocked" }, - meta: { cacheAction, reason: finalized.reason }, - }); - returnTimeoutFallback({ - cacheAction, - reason: "screened", - userId, + fallbackText: getNoKeyMedicationComplianceStatusText(locale), + prompts: (snapshotJson, context) => ({ + systemPrompt: getMedicationComplianceSystemPrompt(locale), + userPrompt: getMedicationComplianceUserPrompt( + snapshotJson, todayKey, - stubText: getNoKeyMedicationComplianceStatusText(locale), - }); - return { - hasProvider: true, - summary: getNoKeyMedicationComplianceStatusText(locale), - medications: medicationSummaries, - cached: true, - updatedAt: null, - }; - } - const summary = finalized.text; - if (!summary) { - throw new Error( - "Medication-compliance-status summary was empty after normalization", - ); - } - const generatedAt = await writeStatusNote({ - userId, - cacheAction, - todayKey, - text: summary, - items: medicationSummaries, - snapshotHash, - }); - return { - hasProvider: true, - summary, - medications: medicationSummaries, - cached: false, - updatedAt: generatedAt.toISOString(), - }; - }, + locale, + context.previousContextBlock, + context.assessmentContextBlock, + context.openerHint, + ), + }), + detail: medicationPlaceholderLines(medicationSnapshots, locale), + }), }; } -export function resolveMedicationComplianceStatusLocale( - locale: string | null | undefined, -): SupportedLocale { - return normalizeLocale(locale); +/** The active, scheduled medications whose adherence the card reports. */ +async function readComplianceMedications(userId: string) { + const medicationRows = await prisma.medication.findMany({ + // v1.16.11 — as-needed (PRN) medications never surface a compliance + // rate (no expected doses). + where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE }, + // v1.15.20 — schedules through the shared compliance select so the + // configured per-dose windows reach this surface like every other. + include: { + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + // v1.16.3 — archived schedule eras for era-aware compliance. + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, + }, + orderBy: { name: "asc" }, + }); + + // A medication with no schedule expects no dose, and `calculateCompliance` + // answers 100 % for that case. The `asNeeded: false` filter above catches + // only the PRN arm; the empty schedule is reachable on a scheduled + // medication and would otherwise report perfect adherence. + return medicationRows.filter(expectsDoses); } + +/** One medication's block in the adherence snapshot. */ +function medicationSnapshot(args: { + medication: ComplianceMedication; + medicationEvents: ComplianceIntakeEvent[]; + categoryMap: Record; + now: Date; + userTz: string; + locale: SupportedLocale; +}) { + const { medication, medicationEvents, categoryMap, now, userTz, locale } = + args; + const events = medicationEvents.filter( + (event) => event.medicationId === medication.id, + ); + + const medicationContext = buildComplianceMedicationContext( + medication, + lastNonSkippedTakenAt(events), + userTz, + ); + // v1.18.0 — one shared cadence-aware expansion pass yields BOTH the + // canonical compliance7 / compliance30 (the values shown in this same + // snapshot) AND the unified dose-history ledger the per-day series is + // built from, so the series can never disagree with compliance30. + const bundle = buildMedicationComplianceBundle( + events, + medication.schedules, + medicationContext, + now, + ); + const compliance7 = bundle.compliance7; + const compliance30 = bundle.compliance30; + + // v1.18.0 — the per-day series honours the medication's cadence + // (daysOfWeek + intervalWeeks + rolling / RRULE / cyclic): only days the + // schedule actually expected a dose produce a point, and each day's rate + // uses the SAME taken / (taken + missed) tally as compliance30. The old + // path divided every event-day by `schedules.length`, treating every + // calendar day as fully dose-due — which made a weekly-cadence med's + // per-day series contradict its own compliance30. Reusing the canonical + // ledger fixes that by construction. + const perDayRecords = dailyComplianceRatesFromLedger( + bundle.ledgerRows, + userTz, + ).map((day) => ({ + measuredAt: day.date, + value: round(day.rate, 1), + })); + + // `applyPayloadBudget` gives the latest-day focus; the compact + // graded series is what reaches the prompt. + const dailyBudgeted = applyPayloadBudget(perDayRecords, { + now, + tz: userTz, + }); + const dailySeries = buildGradedSeriesFromPoints(perDayRecords, now, userTz); + const latestDay = dailyBudgeted.daily[0] ?? null; + + // v1.18.10 — the finished recent-vs-baseline adherence comparison + // + normal-swing verdict, computed from the canonical per-day rate series + // so the model states the trend rather than deriving it. The compliance % + // itself stays server-computed (compliance7/30 above); this adds only the + // trend signal. Adherence reads higher-better; the band is 0–100%. + const signal = buildMetricSignal({ + metric: + locale === "en" + ? `adherence for ${sanitizeForPrompt(medication.name)}` + : `Einnahmetreue für ${sanitizeForPrompt(medication.name)}`, + unit: "%", + direction: "higher-better", + graded: dailySeries, + // Recency is surfaced separately via dataCoverage.newestMeasurementDaysAgo + // (computed after this map); the per-med signal leaves it null. + newestDaysAgo: null, + }); + + return { + medicationId: medication.id, + name: sanitizeForPrompt(medication.name), + dose: sanitizeForPrompt(medication.dose, 50), + category: categoryMap[medication.id] ?? "OTHER", + schedulesPerDay: medication.schedules.length, + compliance7: compliance7.rate, + compliance30: compliance30.rate, + streak: compliance7.streak, + taken7: compliance7.taken, + skipped7: compliance7.skipped, + missed7: compliance7.missed, + ...(signal ? { signal } : {}), + dailySeries, + latestDay, + }; +} + +/** + * The per-medication placeholder rows the UI surfaces alongside the + * model-authored overall `summary`; the model's compliance prompt returns + * only the single `{ summary }` envelope. + */ +function medicationPlaceholderLines( + medicationSnapshots: ReadonlyArray<{ medicationId: string; name: string }>, + locale: SupportedLocale, +): MedicationSummaryItem[] { + return medicationSnapshots.map((medication) => ({ + medicationId: medication.medicationId, + text: normalizeSummaryText( + locale === "de" + ? `${medication.name}: Es liegen noch nicht genügend konsistente Detaildaten für eine belastbare Kurzbewertung vor.` + : `${medication.name}: There is currently not enough consistent detail data for a robust short assessment.`, + ), + })); +} + +const MEDICATION_COMPLIANCE_STATUS_CARD: StatusCardPlugin< + MedicationComplianceResult, + MedicationSummaryItem[] +> = { + scope: "medication-compliance", + codec: medicationComplianceCodec, + unavailableText: (locale) => getNoKeyMedicationComplianceStatusText(locale), + emptyMessage: + "Medication-compliance-status summary was empty after normalization", + build: buildMedicationComplianceSnapshot, +};