diff --git a/CHANGELOG.md b/CHANGELOG.md index e49b9666d..af6f56fff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,51 @@ ### Fixed +- **Mood correlations no longer call five days of noise a strong finding.** The + mood page painted a "Strong" or "Moderate" badge on a mood-versus-sleep, + activity, pulse, weight or blood-pressure card as soon as five days had been + paired, with the day count and the coefficient tucked behind an info icon. + Five points sloping the right way by chance is not a finding, and the rest of + the app has refused to call it one for a while: the weight, blood-pressure + and mood status surfaces all require at least twenty paired days and a result + that a significance test says is unlikely to be chance. The mood page now + requires the same. Fewer cards will show a coefficient, and the ones that do + mean something. A card that cannot show one says which of the two it is: how + many of the twenty days you have so far, or that twenty days went by and + nothing stood out. Those used to look identical. +- **A medication with no schedule no longer reports perfect adherence.** If you + saved a medication without a dose time, the insights list, the blood-pressure + read and the medication-compliance summary all showed it at 100 percent and + folded that into your average, because nothing was expected of it so nothing + could be missed. It was never an adherence figure, and it made every other + medication's average look better than it was. Such a medication is now left + out of adherence entirely, which is what the doctor report has always done. + Add a schedule and it comes back. +- **A day you never logged is no longer counted as a day you took nothing.** The + chart pairing blood-pressure medication continuity against your systolic + readings treated every day without an intake record as a measured zero, so a + week away from the app read as a week off your medication and dragged the + relationship between the two around. Only days that carry an intake record + are used now. A dose you marked as skipped is still a real zero and still + counts; a day you simply did not open the app is not in the chart at all. +- **A lab note this instance cannot decrypt survives an export instead of + vanishing.** If your encryption key was rotated and the old one dropped + before every row had moved, a lab note or a biomarker context written under + the old key came out of a data export as empty. In the file that is + indistinguishable from a note you never wrote, so restoring the export + finished the job and the text was gone with nothing to show it had been + there. Both now export as a short marker saying the text was encrypted with + a key this instance no longer holds, and the marker survives a restore. The + original text is still unrecoverable without the old key; what changes is + that you can see something was lost. +- **A deleted mood entry or custom-metric reading now counts as deleted in the + AI surfaces.** Deleting a reading left it in place with a tombstone, and + three reads had not been told to skip tombstones: the correlation channel + built from a custom metric kept using deleted readings, and the check that + decides whether an AI assessment is still current could not see a mood + deletion or a custom-metric deletion at all, so yesterday's text was re-dated + and served as today's over data you had removed. All three now skip deleted + rows. - **Twenty-eight settings a self-hoster could write down but not actually change.** The `environment:` block in `docker-compose.yml` is a whitelist: compose reads `.env` for `${VAR}` substitution, so a value set there looks diff --git a/messages/de.json b/messages/de.json index ae260abd6..bb25c8cf7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3707,6 +3707,9 @@ "source": "{n} gepaarte Tage · r = {r}", "emptyTitle": "Noch nicht genug gepaarte Daten", "emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen.", + "pendingDescription": "{n} von {needed} gepaarten Tagen. Ein Zusammenhang erscheint, sobald genug Tage da sind, um Muster von Zufall zu unterscheiden.", + "noLinkTitle": "Kein klarer Zusammenhang", + "noLinkDescription": "{n} gepaarte Tage, und nichts hebt sich vom Zufall ab.", "weightTitle": "Stimmung vs. Gewicht", "bloodPressureTitle": "Stimmung vs. Blutdruck", "weightAxis": "Gewicht", diff --git a/messages/en.json b/messages/en.json index bfbb0ab32..f8f3c5599 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3708,6 +3708,9 @@ "sourceLabel": "How this was computed", "emptyTitle": "Not enough paired data yet", "emptyDescription": "Log mood and this metric on the same days to see a correlation.", + "pendingDescription": "{n} of {needed} paired days so far. A correlation appears once there are enough days to tell a pattern from chance.", + "noLinkTitle": "No clear link", + "noLinkDescription": "{n} paired days, and nothing stands out beyond what chance would produce.", "weightTitle": "Mood vs. weight", "bloodPressureTitle": "Mood vs. blood pressure", "weightAxis": "Weight", diff --git a/messages/es.json b/messages/es.json index 2ecddf478..b3f1b33e6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3707,6 +3707,9 @@ "source": "{n} días emparejados · r = {r}", "emptyTitle": "Aún no hay suficientes datos emparejados", "emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación.", + "pendingDescription": "{n} de {needed} días emparejados. La correlación aparece cuando hay días suficientes para distinguir un patrón del azar.", + "noLinkTitle": "Sin relación clara", + "noLinkDescription": "{n} días emparejados, y nada destaca más allá de lo que produciría el azar.", "weightTitle": "Ánimo vs. peso", "bloodPressureTitle": "Ánimo vs. presión arterial", "weightAxis": "Peso", diff --git a/messages/fr.json b/messages/fr.json index 63b62b5ff..c982dd5fa 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3707,6 +3707,9 @@ "source": "{n} jours appariés · r = {r}", "emptyTitle": "Pas encore assez de données appariées", "emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation.", + "pendingDescription": "{n} jours appariés sur {needed}. Une corrélation apparaît dès qu'il y a assez de jours pour distinguer un motif du hasard.", + "noLinkTitle": "Aucun lien net", + "noLinkDescription": "{n} jours appariés, et rien ne se démarque de ce que le hasard produirait.", "weightTitle": "Humeur vs. poids", "bloodPressureTitle": "Humeur vs. tension artérielle", "weightAxis": "Poids", diff --git a/messages/it.json b/messages/it.json index 3cc1dd08e..17cbbc0f3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3707,6 +3707,9 @@ "source": "{n} giorni appaiati · r = {r}", "emptyTitle": "Non ci sono ancora abbastanza dati appaiati", "emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione.", + "pendingDescription": "{n} di {needed} giorni appaiati. La correlazione compare quando i giorni bastano a distinguere un pattern dal caso.", + "noLinkTitle": "Nessun legame chiaro", + "noLinkDescription": "{n} giorni appaiati, e nulla emerge oltre a quanto produrrebbe il caso.", "weightTitle": "Umore vs. peso", "bloodPressureTitle": "Umore vs. pressione sanguigna", "weightAxis": "Peso", diff --git a/messages/ko.json b/messages/ko.json index 062b3e109..6c9a29048 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3708,6 +3708,9 @@ "sourceLabel": "계산 방법", "emptyTitle": "아직 짝지을 데이터가 부족해요", "emptyDescription": "같은 날에 기분과 이 지표를 함께 기록하면 상관관계를 볼 수 있어요.", + "pendingDescription": "짝지은 {n}일 / {needed}일. 우연과 패턴을 구분할 만큼 날이 쌓이면 상관관계가 나타나요.", + "noLinkTitle": "뚜렷한 관계 없음", + "noLinkDescription": "짝지은 {n}일 동안 우연으로 볼 수 있는 범위를 넘는 건 없었어요.", "weightTitle": "기분 대 체중", "bloodPressureTitle": "기분 대 혈압", "weightAxis": "체중", diff --git a/messages/pl.json b/messages/pl.json index de783600b..08b955f02 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3707,6 +3707,9 @@ "source": "{n} sparowanych dni · r = {r}", "emptyTitle": "Za mało sparowanych danych", "emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację.", + "pendingDescription": "{n} z {needed} sparowanych dni. Korelacja pojawi się, gdy dni wystarczy, aby odróżnić wzorzec od przypadku.", + "noLinkTitle": "Brak wyraźnego związku", + "noLinkDescription": "{n} sparowanych dni i nic nie wykracza poza to, co dałby przypadek.", "weightTitle": "Nastrój vs. waga", "bloodPressureTitle": "Nastrój vs. ciśnienie krwi", "weightAxis": "Waga", diff --git a/src/__tests__/unguarded-correlation-guard.test.ts b/src/__tests__/unguarded-correlation-guard.test.ts new file mode 100644 index 000000000..96f0993d4 --- /dev/null +++ b/src/__tests__/unguarded-correlation-guard.test.ts @@ -0,0 +1,117 @@ +/** + * Structural guard on the unguarded Pearson helper. + * + * `pearsonCorrelation` bands a coefficient from |r| alone above a floor of + * five pairs, with no significance test. Its guarded twin + * `significantPearsonCorrelation` sits directly beneath it in the same file + * and exists because of exactly one failure: five days of noise sloping the + * right way reaching a reader labelled "strong". The comment above the twin + * says so in as many words. + * + * The twin was written once and adopted one surface at a time, which is how + * the mood page kept the naive call for four minor lines after the surfaces + * either side of it had moved. Nothing failed when it was skipped, because + * nothing was watching. This is what watches: a new caller of the unguarded + * helper has to be added to the list below, in a diff a reviewer reads, next + * to a written reason. + * + * It is a tripwire, not a proof. It cannot say the remaining entries are + * right — only that the set has not grown without someone editing this file. + * The two entries it carries today are named as open work, not as blessings. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** + * Files that may still call `pearsonCorrelation` directly, and why. + * + * Neither entry is settled. Both are the same migration the user-facing + * surfaces already made, still owed by two payload builders whose consumers + * pin the n >= 5 shape; moving them changes what those payloads contain and + * belongs in its own change with its own tests, not smuggled into this one. + */ +const ALLOWED: Record = { + "app/api/insights/comprehensive/route.ts": + "Five correlations on the /insights envelope (weight x BP, mood x BP, " + + "mood x weight, mood x pulse, BP-medication continuity). Open: they " + + "carry the same small-n exposure the mood page had, and the page's " + + "cards read `n` straight off the envelope.", + "lib/insights/features.ts": + "Eight cross-metric coefficients on the AI feature payload. Open: the " + + "prompt reads them as findings, so the same significance bar applies; " + + "the snapshot shape and its budget tests move with it.", +}; + +/** Every non-test, non-generated source file under `src/`. */ +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +/** + * A call to the unguarded helper. Whitespace-tolerant and anchored on a word + * boundary so `significantPearsonCorrelation(` cannot match: a matcher that + * quietly matches nothing is the failure mode this repository has shipped + * before, so the count is asserted below as well. + */ +const CALL = /(? { + it("finds callers at all, so an empty result cannot read as a pass", () => { + const definition = read("lib/analytics/correlations.ts"); + expect(definition).toMatch(/export function pearsonCorrelation\s*\(/); + expect( + definition, + "the guarded twin must exist for this guard to mean anything", + ).toMatch(/export function significantPearsonCorrelation\s*\(/); + + const callers = sourceFiles().filter( + (rel) => rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)), + ); + expect(callers.length).toBeGreaterThan(0); + }); + + it("names every caller outside the allowlist", () => { + const callers = sourceFiles().filter( + (rel) => rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)), + ); + + expect( + callers.filter((rel) => !(rel in ALLOWED)), + "A new caller of the unguarded `pearsonCorrelation`. A surface a " + + "person reads must use `significantPearsonCorrelation` (n >= 20 " + + "AND p < 0.05). If this one genuinely cannot, add it to ALLOWED " + + "in this file with the reason, so the exception is reviewed rather " + + "than assumed.", + ).toEqual([]); + }); + + it("keeps the allowlist honest by refusing stale entries", () => { + const callers = new Set( + sourceFiles().filter( + (rel) => + rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)), + ), + ); + + // An entry whose file no longer calls the helper is a licence nobody + // needs any more; leaving it standing is how the next one gets waved in. + expect( + Object.keys(ALLOWED).filter((rel) => !callers.has(rel)), + "this file is on the allowlist but no longer calls the unguarded " + + "helper — drop the entry", + ).toEqual([]); + }); +}); diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 5f2a4eedd..372a313ea 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -776,15 +776,20 @@ describe("GET /api/insights/comprehensive — mood × metric local-day pairing ( scheduleRevisions: [], pauseEras: [], }; - // Intake taken on the first three days, missed on the last two → a - // non-degenerate continuity series. - const intakeEvents = days.slice(0, 3).map((d) => ({ + // Intake taken on the first three days, logged as skipped on the last + // two → a non-degenerate continuity series. The skips used to be absent + // rows: a day with no record read as a measured 0 % continuity, which is + // the fabrication the route no longer makes, and a fixture that relies on + // it would be pinning the defect rather than the timezone question this + // test asks. Recording the two days as skips keeps five paired days AND + // keeps every one of them a thing the person actually logged. + const intakeEvents = days.map((d, i) => ({ medicationId: "med-bp", userId: "user-comp-1", deletedAt: null, scheduledFor: new Date(`${d}T07:00:00Z`), - takenAt: new Date(`${d}T07:05:00Z`), - skipped: false, + takenAt: i < 3 ? new Date(`${d}T07:05:00Z`) : null, + skipped: i >= 3, })); function primeMocks() { @@ -827,3 +832,227 @@ describe("GET /api/insights/comprehensive — mood × metric local-day pairing ( expect(utcBody.data.bpMedicationCorrelation).not.toBeNull(); }); }); + +// A medication with no schedule expects no dose, so it has no adherence to +// report. `calculateCompliance` answers `rate: 100, totalExpected: 0` for that +// case — arithmetically true and a lie on a surface that reads the number as +// adherence. The doctor report has excluded both the PRN arm and the empty +// schedule since it was written, with the reason beside it; this envelope +// filtered only PRN, so a scheduled medication saved without a schedule (which +// `POST /api/medications` accepts) reported perfect adherence to the reader and +// to the prompt built from the same array. +describe("GET /api/insights/comprehensive — no adherence without an expectation", () => { + const EMPTY_AGGREGATE = { + summaries: {}, + bpRawRows: { sys: [], dia: [] }, + weightRawRows: [], + dailyByType: {}, + firstMeasurementAt: null, + totalMeasurements: 0, + }; + + type MedBody = { + data: { + medications: Array<{ + id: string; + name: string; + compliance7: number; + compliance30: number; + }>; + }; + }; + + function scheduledMed(id: string, name: string, schedules: unknown[]) { + return { + id, + name, + dose: "5mg", + active: true, + asNeeded: false, + createdAt: new Date("2026-04-01T00:00:00Z"), + schedules, + scheduleRevisions: [], + pauseEras: [], + }; + } + + const SCHEDULE = { + id: "sch-1", + windowStart: "07:00", + windowEnd: "08:00", + timesOfDay: ["07:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: [], + }; + + it("leaves a scheduled medication with no schedule out of the compliance list", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + (buildComprehensiveAggregate as ReturnType).mockResolvedValue( + EMPTY_AGGREGATE, + ); + (prisma.medication.findMany as ReturnType).mockResolvedValue([ + scheduledMed("med-none", "Unscheduled", []), + scheduledMed("med-real", "Amlodipine", [SCHEDULE]), + ]); + + const body = (await (await callGet(makeReq())).json()) as MedBody; + + const names = body.data.medications.map((m) => m.name); + expect(names).not.toContain("Unscheduled"); + // The medication that DOES expect a dose still reports, so this is a + // filter and not a blanket suppression. + expect(names).toContain("Amlodipine"); + }); + + it("never reports 100 % for a medication with nothing expected of it", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + (buildComprehensiveAggregate as ReturnType).mockResolvedValue( + EMPTY_AGGREGATE, + ); + (prisma.medication.findMany as ReturnType).mockResolvedValue([ + scheduledMed("med-none", "Unscheduled", []), + ]); + + const body = (await (await callGet(makeReq())).json()) as MedBody; + + expect(body.data.medications).toEqual([]); + }); +}); + +// The medication-continuity correlation joined every day that had a systolic +// reading against `takenByDay.get(day) ?? 0`. A day the reader simply never +// logged an intake for therefore entered the scatter and the coefficient as a +// measured 0 % continuity. Absence is not zero anywhere else in this project +// and it is not zero here: a day with a logged skip is a real zero and stays, +// a day with no record at all drops out. +describe("GET /api/insights/comprehensive — an unlogged day is not a zero", () => { + type BpBody = { + data: { + bpMedicationScatterData: Array<{ continuityPct: number; sysBP: number }>; + bpMedicationCorrelation: { n: number } | null; + }; + }; + + const DAYS = [ + "2026-05-01", + "2026-05-02", + "2026-05-03", + "2026-05-04", + "2026-05-05", + ]; + + const MEDICATION = { + id: "med-bp", + name: "Amlodipine", + dose: "5mg", + active: true, + asNeeded: false, + createdAt: new Date("2026-04-01T00:00:00Z"), + schedules: [ + { + id: "sch-1", + windowStart: "07:00", + windowEnd: "08:00", + timesOfDay: ["07:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: [], + }, + ], + scheduleRevisions: [], + pauseEras: [], + }; + + function primeBp( + events: Array<{ day: string; takenAt: Date | null; skipped: boolean }>, + ) { + (buildComprehensiveAggregate as ReturnType).mockResolvedValue( + { + summaries: {}, + bpRawRows: { sys: [], dia: [] }, + weightRawRows: [], + dailyByType: { + BLOOD_PRESSURE_SYS: DAYS.map((d, i) => ({ + day: d, + value: 120 + i * 2, + })), + }, + firstMeasurementAt: new Date(), + totalMeasurements: 10, + }, + ); + (prisma.medication.findMany as ReturnType).mockResolvedValue([ + MEDICATION, + ]); + ( + prisma.medicationIntakeEvent.findMany as ReturnType + ).mockResolvedValue( + events.map((e) => ({ + medicationId: "med-bp", + userId: "user-comp-1", + deletedAt: null, + scheduledFor: new Date(`${e.day}T07:00:00Z`), + takenAt: e.takenAt, + skipped: e.skipped, + })), + ); + vi.mocked(getMedicationCategories).mockResolvedValue({ + "med-bp": "BLOOD_PRESSURE", + }); + } + + it("keeps only the days that carry an intake record", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Three days logged; the last two were never touched. + primeBp( + DAYS.slice(0, 3).map((day) => ({ + day, + takenAt: new Date(`${day}T07:05:00Z`), + skipped: false, + })), + ); + + const body = (await (await callGet(makeReq())).json()) as BpBody; + + expect(body.data.bpMedicationScatterData).toHaveLength(3); + // The two unlogged days would have entered as 0 % against their own + // systolic reading; both are gone. + expect(body.data.bpMedicationScatterData.map((p) => p.sysBP)).not.toContain( + 126, + ); + expect( + body.data.bpMedicationScatterData.every((p) => p.continuityPct === 100), + ).toBe(true); + }); + + it("keeps a logged skip, which is a real zero", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeBp([ + ...DAYS.slice(0, 3).map((day) => ({ + day, + takenAt: new Date(`${day}T07:05:00Z`), + skipped: false, + })), + { day: DAYS[3], takenAt: null, skipped: true }, + ]); + + const body = (await (await callGet(makeReq())).json()) as BpBody; + + expect(body.data.bpMedicationScatterData).toHaveLength(4); + expect(body.data.bpMedicationScatterData).toContainEqual({ + continuityPct: 0, + sysBP: 126, + }); + }); +}); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index a3cf6e902..a063ea1c5 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -17,6 +17,7 @@ import { import { buildComplianceMedicationContext, calculateCompliance, + expectsDoses, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; @@ -365,7 +366,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) { // ── Medication compliance ──────────────────────────────── // Bounded reads — left untouched per the directive. - const medications = await prisma.medication.findMany({ + 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 }, @@ -379,6 +380,12 @@ export async function buildComprehensiveResponse(user: AuthedUser) { pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }); + // 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); + const categoryMap = await getMedicationCategories( medications.map((m) => m.id), ); @@ -497,14 +504,23 @@ export async function buildComprehensiveResponse(user: AuthedUser) { } const takenByDay = new Map(); + // Days that carry an intake record of ANY kind. A logged skip is a real + // zero and belongs in the series; a day the person never touched is not a + // zero, it is nothing, and pairing it against that day's systolic reading + // invented a data point they never produced. Absence is not zero, here as + // everywhere else in this project, so the unlogged day drops out of both + // the scatter and the coefficient rather than dragging continuity down. + const loggedDays = new Set(); for (const event of bpMedicationEvents) { - if (event.skipped || !event.takenAt) continue; const dayKey = event.scheduledFor.toISOString().slice(0, 10); + loggedDays.add(dayKey); + if (event.skipped || !event.takenAt) continue; takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1); } const pairs: Array<{ a: number; b: number; date: Date }> = []; for (const [dayKey, avgSys] of sysByDay.entries()) { + if (!loggedDays.has(dayKey)) continue; const taken = takenByDay.get(dayKey) ?? 0; const continuity = Math.min(1, taken / expectedBpIntakesPerDay); pairs.push({ diff --git a/src/components/insights/mood/__tests__/mood-correlation-cards.test.tsx b/src/components/insights/mood/__tests__/mood-correlation-cards.test.tsx new file mode 100644 index 000000000..102e6a467 --- /dev/null +++ b/src/components/insights/mood/__tests__/mood-correlation-cards.test.tsx @@ -0,0 +1,104 @@ +/** + * What the mood correlation card is allowed to claim. + * + * The card used to paint a "Strong" badge from any coefficient it was handed, + * as long as five days had been paired. The coefficient itself now clears the + * significance bar before it leaves the server, so the card's job here is the + * other half: when the bar refuses, say which bar and how far off the reader + * is, rather than falling back to the same "not enough paired data" blank that + * a reader with no data at all sees. + */ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + MoodCorrelationCards, + type MoodMetricCorrelationData, +} from "../mood-correlation-cards"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const NOTHING: MoodMetricCorrelationData = { + result: null, + points: [], + n: 0, + suppressed: "insufficientPairs", +}; + +/** The sleep card's own markup — the four siblings carry their own copy. */ +function renderSleep(sleep: MoodMetricCorrelationData) { + const html = render( + , + ); + const start = html.indexOf('data-kind="sleep"'); + const end = html.indexOf('data-kind="steps"'); + return html.slice(start, end); +} + +describe("", () => { + it("shows no strength badge for a refused correlation", () => { + const html = renderSleep({ + result: null, + points: Array.from({ length: 5 }, (_, i) => ({ x: i, y: i })), + n: 5, + suppressed: "insufficientPairs", + }); + + expect(html).not.toContain("Strong"); + expect(html).not.toContain("Moderate"); + }); + + it("names how many paired days are still missing instead of a bare blank", () => { + const html = renderSleep({ + result: null, + points: Array.from({ length: 5 }, (_, i) => ({ x: i, y: i })), + n: 5, + suppressed: "insufficientPairs", + }); + + // The reader is told where they stand, not only that something is absent. + expect(html).toContain("5 of 20"); + }); + + it("keeps the plain empty copy when nothing has been paired at all", () => { + const html = renderSleep(NOTHING); + + expect(html).toContain("Not enough paired data yet"); + expect(html).not.toContain("0 of 20"); + }); + + it("says a twenty-day pairing showed no reliable link", () => { + const html = renderSleep({ + result: null, + points: Array.from({ length: 20 }, (_, i) => ({ x: i, y: i })), + n: 20, + suppressed: "notSignificant", + }); + + expect(html).toContain("No clear link"); + expect(html).not.toContain("Not enough paired data yet"); + expect(html).not.toContain("Weak"); + }); + + it("still shows the badge and the n · r caption for a correlation that cleared the bar", () => { + const html = renderSleep({ + result: { r: 0.82, strength: "stark", n: 24 }, + points: Array.from({ length: 24 }, (_, i) => ({ x: i, y: i })), + n: 24, + }); + + expect(html).toContain("Strong"); + expect(html).toContain("24 paired days"); + }); +}); diff --git a/src/components/insights/mood/mood-correlation-cards.tsx b/src/components/insights/mood/mood-correlation-cards.tsx index d01a7265c..804bf52ff 100644 --- a/src/components/insights/mood/mood-correlation-cards.tsx +++ b/src/components/insights/mood/mood-correlation-cards.tsx @@ -20,7 +20,9 @@ import { MoodExplainerIcon } from "./mood-explainer-icon"; * pulse) pair. The Pearson math comes pre-computed from * `/api/mood/insights` (single source of truth with the LLM snapshot). * Below-threshold pairs render an EmptyState rather than a misleading - * near-zero scatter. + * near-zero scatter — and the EmptyState names which threshold, because a + * pairing that is still building and one that found nothing are different + * answers and used to read as the same blank. */ const ScatterCorrelationChart = dynamic( @@ -46,8 +48,17 @@ export interface MoodMetricCorrelationData { result: CorrelationResult | null; points: MoodCorrelationPoint[]; n: number; + /** Which bar the pairing fell short of, when there is no coefficient. */ + suppressed?: "insufficientPairs" | "notSignificant"; } +/** + * The paired-day floor the server's significance gate enforces. Repeated + * here only to tell the reader how far off they are; the decision itself is + * the server's and arrives as `suppressed`. + */ +const MIN_PAIRED_DAYS = 20; + type MetricKind = "sleep" | "steps" | "pulse" | "weight" | "bloodPressure"; const TITLE_KEY: Record = { @@ -95,7 +106,33 @@ function MoodCorrelationCard({ className?: string; }) { const { t } = useTranslations(); - const hasResult = data.result != null && data.n >= 5; + const hasResult = data.result != null; + + // Three different silences, and the reader deserves to know which one they + // are looking at: nothing paired yet, some days paired but not enough to + // separate a pattern from chance, or enough days and no pattern in them. + // The last one is a finding, not a gap, and used to render as the same + // "not enough data" blank as the first. + const emptyCopy = + data.suppressed === "notSignificant" + ? { + title: t("insights.mood.correlation.noLinkTitle"), + description: t("insights.mood.correlation.noLinkDescription", { + n: data.n, + }), + } + : data.n > 0 + ? { + title: t("insights.mood.correlation.emptyTitle"), + description: t("insights.mood.correlation.pendingDescription", { + n: data.n, + needed: MIN_PAIRED_DAYS, + }), + } + : { + title: t("insights.mood.correlation.emptyTitle"), + description: t("insights.mood.correlation.emptyDescription"), + }; return ( } - title={t("insights.mood.correlation.emptyTitle")} - description={t("insights.mood.correlation.emptyDescription")} + title={emptyCopy.title} + description={emptyCopy.description} /> )} diff --git a/src/lib/analytics/compliance/display.ts b/src/lib/analytics/compliance/display.ts index fa379412f..47f7feb00 100644 --- a/src/lib/analytics/compliance/display.ts +++ b/src/lib/analytics/compliance/display.ts @@ -243,6 +243,28 @@ export function buildComplianceDisplay( * can be driven deterministically from a caller * that already pinned its own `now`. */ +/** + * Whether a medication expects any dose at all, and so has an adherence rate + * worth showing. + * + * `calculateCompliance` below answers `rate: 100, totalExpected: 0` for a + * medication with no schedule. That is arithmetically honest — nothing was + * expected, so nothing was missed — and it is a lie on every surface that + * reads the number as adherence. PRN is the obvious arm and every caller + * already filtered it; the empty schedule on a NON-PRN medication is the arm + * that got through, and it is reachable: `POST /api/medications` accepts + * `schedules ?? []` on a scheduled medication. The doctor report has excluded + * both arms since it was written, so the report never prints a fabricated + * 100 %; the same reason applies to every other surface, so the two arms live + * in one predicate rather than being re-derived at each call site. + */ +export function expectsDoses(medication: { + asNeeded: boolean; + schedules: readonly unknown[]; +}): boolean { + return !medication.asNeeded && medication.schedules.length > 0; +} + export function calculateCompliance( events: IntakeEvent[], schedules: ComplianceSchedule[], diff --git a/src/lib/doctor-report-helpers.ts b/src/lib/doctor-report-helpers.ts index c17e4aa6e..3c5e2c93d 100644 --- a/src/lib/doctor-report-helpers.ts +++ b/src/lib/doctor-report-helpers.ts @@ -21,6 +21,7 @@ import { userDayKey } from "@/lib/tz/resolver"; import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; import { buildComplianceMedicationContext, + expectsDoses, lastNonSkippedTakenAt, tallyComplianceFromLedger, } from "@/lib/analytics/compliance"; @@ -363,9 +364,9 @@ export function buildLedgerCompliance( 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; + // the report never prints a fabricated 100 %. Both arms now live in the + // shared predicate, which is what the other adherence surfaces read. + if (!expectsDoses(med)) continue; const events = eventsByMedId.get(med.id) ?? []; const ctx = buildComplianceMedicationContext( diff --git a/src/lib/export/__tests__/records-backup.test.ts b/src/lib/export/__tests__/records-backup.test.ts index c94532062..50cd952c0 100644 --- a/src/lib/export/__tests__/records-backup.test.ts +++ b/src/lib/export/__tests__/records-backup.test.ts @@ -19,6 +19,7 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptNoteToBytes } from "@/lib/labs/store"; import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; import { encryptDocumentSummary } from "@/lib/documents/store"; +import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import { buildRecordsBackupSection, countRecordsBackupSection, @@ -450,10 +451,14 @@ describe("buildRecordsBackupSection", () => { }); }); - it("fails soft (null, not a throw) on an undecryptable note", async () => { + it("marks an undecryptable note (not a throw, and not a silent null)", async () => { const prisma = buildMockPrisma(); - // Corrupt the ciphertext so decrypt throws internally — the row must - // still come back with `note: null`, never abort the whole backup. + // Corrupt the ciphertext so decrypt throws internally. The row must still + // come back and must never abort the whole backup — that part is + // unchanged. What it must NOT come back as is `null`, which this test used + // to require: in an export file a null note is byte-identical to a note + // that was never written, so the restore writes nothing and the loss is + // invisible. It comes back as a marker instead. prisma.labResult.findMany.mockResolvedValueOnce([ { panel: null, @@ -471,6 +476,6 @@ describe("buildRecordsBackupSection", () => { ]); const section = await buildRecordsBackupSection(prisma as never, USER_ID); - expect(section.labResults[0].note).toBeNull(); + expect(section.labResults[0].note).toBe(UNREADABLE_EXPORT_MARKER); }); }); diff --git a/src/lib/export/coach-backup.ts b/src/lib/export/coach-backup.ts index c4894fc7f..19c1c8363 100644 --- a/src/lib/export/coach-backup.ts +++ b/src/lib/export/coach-backup.ts @@ -53,6 +53,7 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker"; import { recordUnknownKeys, type RestoreSkipLog, @@ -169,7 +170,7 @@ function decryptTurnSoft(bytes: Uint8Array | null): string | null { try { return decryptFromBytes(bytes); } catch { - return "[unreadable: encrypted with a key this instance no longer holds]"; + return UNREADABLE_EXPORT_MARKER; } } diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index 8d0130372..2948689e8 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -7,14 +7,16 @@ * verbatim. The DR path never decrypts document content or summaries. * * Every read is scoped to the owner. Soft-deleted rows are excluded so a - * restore cannot resurrect tombstones. Other encrypted free-text fields keep - * the established fail-soft portable representation and are re-encrypted by - * the restore importer. + * restore cannot resurrect tombstones. Other encrypted free-text fields are + * carried as plaintext and re-encrypted by the restore importer; one this + * instance cannot decrypt is carried as a visible marker rather than as + * nothing (see `readForExport` below). */ import { Buffer } from "node:buffer"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; -import { decryptNoteSoft } from "@/lib/labs/store"; -import { decryptContextSoft } from "@/lib/labs/biomarker-store"; +import { decryptNoteFromBytes } from "@/lib/labs/store"; +import { decryptContextFromBytes } from "@/lib/labs/biomarker-store"; +import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker"; import { decryptDocumentSummary } from "@/lib/documents/store"; import { getEvent } from "@/lib/logging/context"; import { @@ -44,6 +46,39 @@ export const WORKOUTS_MANIFEST_NOTE = "steps) are included. GPS routes and per-sample heart-rate/pace time " + "series are not included in this export."; +/** + * Decrypt an encrypted free-text column for a portable export, or say plainly + * that this one could not be read. + * + * A fail-soft decrypt answers `null` on a wrong or dropped key. In a list view + * that is right — one bad row must not take the page down. In an export it is + * not: `null` is exactly what a column that was never written looks like, so + * the restore importer writes nothing and a note the person did write + * disappears without anyone being told. The Coach transcript and the doctor + * report both refuse that trade, one with a placeholder sentence and one with + * a rendered flag; this follows the same rule. + * + * The warning the soft helpers emitted is kept, so a systemic key gap still + * shows up in the wide event rather than only in the file. + */ +function readForExport( + buf: Uint8Array | null, + label: string, + decode: (bytes: Uint8Array) => string, +): string | null { + if (!buf || buf.byteLength === 0) return null; + try { + return decode(buf); + } catch (err) { + getEvent()?.addWarning( + `${label} decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return UNREADABLE_EXPORT_MARKER; + } +} + export interface LabResultBackupEntry { /** Present in canonical DR payloads so rows keep a stable identity. */ id?: string; @@ -339,7 +374,13 @@ export async function buildRecordsBackupSection( deletedAt: r.deletedAt?.toISOString() ?? null, note: null, } - : { note: decryptNoteSoft(r.noteEncrypted) }), + : { + note: readForExport( + r.noteEncrypted, + "lab note", + decryptNoteFromBytes, + ), + }), panel: r.panel, analyte: r.analyte, value: r.value, @@ -369,7 +410,11 @@ export async function buildRecordsBackupSection( upperBound: b.upperBound, panel: b.panel, hidden: b.hidden, - context: decryptContextSoft(b.contextEncrypted), + context: readForExport( + b.contextEncrypted, + "biomarker context", + decryptContextFromBytes, + ), })); const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery diff --git a/src/lib/export/unreadable-marker.ts b/src/lib/export/unreadable-marker.ts new file mode 100644 index 000000000..dca59f216 --- /dev/null +++ b/src/lib/export/unreadable-marker.ts @@ -0,0 +1,19 @@ +/** + * What an export writes where a row could not be decrypted. + * + * A portable export carries plaintext, so an encrypted column that this + * instance can no longer read has to become something. The fail-soft decrypt + * helpers answer `null`, which in an export file is indistinguishable from a + * column that was never written — and a restore then writes nothing, so the + * loss is complete and silent. It is not: the ciphertext was there, and the + * person is entitled to know a key gap ate it rather than to find a note they + * remember writing simply gone. + * + * The marker is a sentence, not an empty string, for the same reason the Coach + * transcript's placeholder is: an empty string reads as "there was nothing + * here", and that is not what happened. It survives a round trip through the + * restore importer like any other text, so the gap stays visible in the + * restored account instead of closing over. + */ +export const UNREADABLE_EXPORT_MARKER = + "[unreadable: encrypted with a key this instance no longer holds]"; diff --git a/src/lib/fhir/resources/observation.ts b/src/lib/fhir/resources/observation.ts index 07176a653..45a8bc4a8 100644 --- a/src/lib/fhir/resources/observation.ts +++ b/src/lib/fhir/resources/observation.ts @@ -177,8 +177,13 @@ function adherenceObservations( for (const [name, comp] of Object.entries(data.compliance)) { if (comp.total <= 0) continue; // Integer percent — the one canonical rounding the app card + PDF use, so - // a clinician sees the same adherence figure on every surface. - const rate = adherenceRatePercent(comp.taken, comp.total) ?? 0; + // a clinician sees the same adherence figure on every surface. The helper + // returns null exactly when nothing was expected, and a `?? 0` here would + // put a clinical claim of 0 % adherence into an exported bundle in that + // case. The `total <= 0` guard above already means it cannot happen; skip + // rather than coalesce so it stays impossible if that guard ever moves. + const rate = adherenceRatePercent(comp.taken, comp.total); + if (rate === null) continue; observations.push({ resourceType: "Observation", id: nextId(), diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index ec0017405..437c065da 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -260,3 +260,76 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F expect(parsed.text).not.toContain("metric:"); }); }); + +// The BP-status gate reads its medications' adherence to decide what to say +// about a reading. `calculateCompliance` answers `rate: 100, totalExpected: 0` +// for a medication with no schedule, so a blood-pressure medication saved +// without one told the model the person was perfectly adherent — the exact +// fabrication the doctor report has excluded since it was written. +describe("generateBloodPressureStatusForUser — no rate without an expectation", () => { + const SCHEDULE = { + id: "sch-1", + windowStart: "07:00", + windowEnd: "08:00", + timesOfDay: ["07:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: [], + }; + + function bpMed(id: string, name: string, schedules: unknown[]) { + return { + id, + name, + dose: "5mg", + active: true, + asNeeded: false, + createdAt: new Date(Date.now() - 60 * dayMs), + schedules, + scheduleRevisions: [], + pauseEras: [], + }; + } + + it("omits a blood-pressure medication that expects no dose", async () => { + const now = new Date(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now }, + { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now }, + ] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + bpMed("med-empty", "Unscheduled", []), + bpMed("med-real", "Amlodipine", [SCHEDULE]), + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + vi.mocked(getMedicationCategories).mockResolvedValue({ + "med-empty": "BLOOD_PRESSURE", + "med-real": "BLOOD_PRESSURE", + }); + + const captured: { userPrompt: string | null } = { userPrompt: null }; + stubCompletion('{"summary":"OK"}', captured); + + await generateBloodPressureStatusForUser("user-1", { locale: "en" }); + + const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]); + const names = snapshot.bpMedications.map((m: { name: string }) => m.name); + expect(names).not.toContain("Unscheduled"); + expect(names).toContain("Amlodipine"); + }); +}); diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index 1e3a850b0..e480a3f65 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -360,3 +360,85 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1 expect(parsed.medications[0].text).not.toContain("metric:"); }); }); + +// A medication with no schedule expects no dose. `calculateCompliance` answers +// `rate: 100, totalExpected: 0` for that case, and this surface averaged that +// 100 into `overall.averageCompliance30` and handed it to the model as the +// person's adherence. The doctor report has excluded the empty schedule +// alongside PRN since it was written — "so the report never prints a +// fabricated 100 %" — and the same reason applies wherever a rate is shown. +describe("generateMedicationComplianceStatusForUser — no rate without an expectation", () => { + function scheduleless(now: Date) { + return { + id: "med-empty", + name: "Unscheduled", + dose: "5mg", + active: true, + asNeeded: false, + createdAt: new Date(now.getTime() - 60 * dayMs), + schedules: [], + scheduleRevisions: [], + pauseEras: [], + }; + } + + it("keeps a schedule-less medication out of the snapshot and its average", async () => { + const now = new Date(); + const scheduled = { ...medFixture(now), asNeeded: false }; + const events = Array.from({ length: 30 }, (_, day) => { + const scheduledFor = new Date(now.getTime() - day * dayMs); + // Half the doses taken, so the real medication sits well below 100 %. + return { + medicationId: "med-1", + scheduledFor, + takenAt: day % 2 === 0 ? scheduledFor : null, + skipped: false, + }; + }); + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + scheduled, + scheduleless(now), + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + events as never, + ); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + const captured: { userPrompt: string | null } = { userPrompt: null }; + stubCompletion('{"summary":"OK","medications":[]}', captured); + + await generateMedicationComplianceStatusForUser("user-1", { locale: "en" }); + + const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]); + const names = snapshot.medications.map((m: { name: string }) => m.name); + expect(names).not.toContain("Unscheduled"); + expect(names).toContain("Ramipril"); + expect(snapshot.overall.medicationCount).toBe(1); + // The fabricated 100 is no longer half of the average handed to the model. + expect(snapshot.overall.averageCompliance30).toBeLessThan(100); + }); + + it("reports no active medications when the only one has no schedule", async () => { + const now = new Date(); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + scheduleless(now), + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + + const outcome = (await generateMedicationComplianceStatusForUser("user-1", { + locale: "en", + })) as { summary: string | null; medications: unknown[] }; + + expect(outcome.medications).toEqual([]); + expect(outcome.summary).toContain("no active medications"); + // Nothing to assess, so no model call and no cache row either. + expect(runStatusCompletion).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/insights/__tests__/mood-aggregates.test.ts b/src/lib/insights/__tests__/mood-aggregates.test.ts index 50d04848f..05a3bc8de 100644 --- a/src/lib/insights/__tests__/mood-aggregates.test.ts +++ b/src/lib/insights/__tests__/mood-aggregates.test.ts @@ -292,25 +292,43 @@ describe("computeHeatmapCells", () => { describe("computeMoodMetricCorrelation", () => { it("pairs on dayOffset and returns scatter points + a coefficient", () => { - const moodDaily = [ - { dayOffset: 0, value: 5 }, - { dayOffset: 1, value: 4 }, - { dayOffset: 2, value: 3 }, - { dayOffset: 3, value: 2 }, - { dayOffset: 4, value: 1 }, - ]; + // Twenty days, not five. This test used to run on five and assert a + // coefficient came back, which pinned the very thing the significance + // gate exists to refuse: five points that happen to line up are not a + // finding. The pairing and the scatter projection are what it is asking + // about, and they are unchanged — it just has to ask above the bar. + const moodDaily = Array.from({ length: 20 }, (_, i) => ({ + dayOffset: i, + value: 5 - (i % 5), + })); // Perfectly positively correlated metric. const metricDaily = moodDaily.map((b) => ({ dayOffset: b.dayOffset, value: b.value * 10, })); const corr = computeMoodMetricCorrelation(moodDaily, metricDaily, NOW); - expect(corr.n).toBe(5); - expect(corr.points).toHaveLength(5); + expect(corr.n).toBe(20); + expect(corr.points).toHaveLength(20); expect(corr.result?.r).toBe(1); + expect(corr.suppressed).toBeUndefined(); expect(corr.points[0]).toEqual({ x: 5, y: 50 }); }); + it("refuses the same shape at five days", () => { + const moodDaily = Array.from({ length: 5 }, (_, i) => ({ + dayOffset: i, + value: 5 - i, + })); + const metricDaily = moodDaily.map((b) => ({ + dayOffset: b.dayOffset, + value: b.value * 10, + })); + const corr = computeMoodMetricCorrelation(moodDaily, metricDaily, NOW); + expect(corr.n).toBe(5); + expect(corr.result).toBeNull(); + expect(corr.suppressed).toBe("insufficientPairs"); + }); + it("returns a null coefficient below the minimum pair count", () => { const moodDaily = [ { dayOffset: 0, value: 5 }, diff --git a/src/lib/insights/__tests__/mood-metric-correlation-significance.test.ts b/src/lib/insights/__tests__/mood-metric-correlation-significance.test.ts new file mode 100644 index 000000000..7f4456c77 --- /dev/null +++ b/src/lib/insights/__tests__/mood-metric-correlation-significance.test.ts @@ -0,0 +1,78 @@ +/** + * The mood page's cross-metric correlations clear the same bar as every + * other user-facing correlation surface. + * + * `significantPearsonCorrelation` was written for exactly this failure — its + * own comment names a five-point r ≈ 0.7 fluke rendering as "stark" — and the + * weight, blood-pressure and mood-status surfaces were moved onto it. The + * mood page stayed on the plain `pearsonCorrelation` with its floor of five + * pairs and no significance test, so the same five points that the other + * surfaces refuse still painted a "strong" badge here. + * + * These assertions are about what the reader is told, not about which helper + * is called: below the bar there is no coefficient to show, and the card is + * handed a reason it can name instead of a blank. + */ +import { describe, expect, it } from "vitest"; + +import { + computeMoodMetricCorrelation, + type DailyPoint, +} from "@/lib/insights/mood-aggregates"; + +const NOW = new Date("2026-06-01T12:00:00.000Z"); + +/** One point per day-offset, values taken from the list in order. */ +function series(values: number[]): DailyPoint[] { + return values.map((value, i) => ({ dayOffset: i, value })); +} + +describe("computeMoodMetricCorrelation — significance gate", () => { + it("refuses a five-day strong-looking slope and says the pairs are too few", () => { + const mood = series([1, 2, 3, 4, 5]); + const metric = series([1, 2, 2, 4, 3]); + + const out = computeMoodMetricCorrelation(mood, metric, NOW); + + expect(out.n).toBe(5); + // No coefficient reaches the card, so no strength band can be painted. + expect(out.result).toBeNull(); + // ... and the card is told WHY, so it can say so rather than showing an + // unexplained blank. + expect(out.suppressed).toBe("insufficientPairs"); + }); + + it("refuses twenty noisy days as not significant rather than as too few", () => { + const ys = [5, 2, 7, 1, 6, 3, 8, 2, 5, 4, 6, 1, 7, 3, 5, 2, 6, 4, 5, 3]; + const mood = series(ys.map((_, i) => i)); + const metric = series(ys); + + const out = computeMoodMetricCorrelation(mood, metric, NOW); + + expect(out.n).toBe(20); + expect(out.result).toBeNull(); + expect(out.suppressed).toBe("notSignificant"); + }); + + it("surfaces twenty days of a real relationship", () => { + const mood = series(Array.from({ length: 20 }, (_, i) => i)); + const metric = series( + Array.from({ length: 20 }, (_, i) => i * 2 + (i % 2 === 0 ? 0.3 : -0.3)), + ); + + const out = computeMoodMetricCorrelation(mood, metric, NOW); + + expect(out.suppressed).toBeUndefined(); + expect(out.result).not.toBeNull(); + expect(out.result!.n).toBe(20); + expect(out.result!.strength).toBe("stark"); + }); + + it("reports no overlap as too few pairs, not as an insignificant finding", () => { + const out = computeMoodMetricCorrelation(series([3, 4]), [], NOW); + + expect(out.n).toBe(0); + expect(out.result).toBeNull(); + expect(out.suppressed).toBe("insufficientPairs"); + }); +}); diff --git a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts index f37ced16d..c26ee7d95 100644 --- a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts +++ b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts @@ -50,4 +50,44 @@ describe("prompt reads exclude tombstoned rows", () => { expect(intakeRead).not.toBeNull(); expect(intakeRead![0]).toContain("deletedAt: null"); }); + // A soft delete has to be able to reach the CACHE GATE as well as the + // prompt. `buildStatusInputHash` fingerprints "how much data is there and + // how new is it"; a tombstoned row that still counts leaves the fingerprint + // unchanged, the gate re-dates yesterday's assessment, and text reasoning + // over a deleted reading is served as today's. The measurement arm of that + // same query already filters, which is what makes the two that did not so + // easy to miss. + it("the status input hash filters its mood aggregate", () => { + const src = read("../status-cache.ts"); + const moodRead = src.match( + /moodEntry\s*\n?\s*\.aggregate\(\{[\s\S]{0,800}?where:\s*\{[^}]*\}/, + ); + expect(moodRead).not.toBeNull(); + expect(moodRead![0]).toContain("deletedAt: null"); + }); + + it("the status input hash filters its custom-metric entry reads", () => { + const src = read("../status-cache.ts"); + // Both the count and the newest-entry probe: either one left unfiltered + // keeps the fingerprint still after a delete. + expect(src).toMatch( + /_count:\s*\{\s*select:\s*\{\s*entries:\s*\{[\s\S]{0,200}?deletedAt:\s*null/, + ); + const newest = src.match( + /entries:\s*\{\s*where:\s*\{[^}]*\}[\s\S]{0,200}?take:\s*1/, + ); + expect(newest).not.toBeNull(); + expect(newest![0]).toContain("deletedAt: null"); + }); + + it("the custom-metric correlation channel filters its entry read", () => { + // The parent metric's own tombstone is filtered; its entries' were not, + // so a reading the person deleted still shaped the correlation cards. + const src = read("../correlation-channel-series.ts"); + const entryRead = src.match( + /entries:\s*\{[\s\S]{0,400}?where:\s*\{[^}]*\}/, + ); + expect(entryRead).not.toBeNull(); + expect(entryRead![0]).toContain("deletedAt: null"); + }); }); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 0efd5f868..378988eea 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -24,6 +24,7 @@ import { import { buildComplianceMedicationContext, calculateCompliance, + expectsDoses, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; @@ -324,7 +325,7 @@ export async function prepareBloodPressureStatusForUser( weightVsSystolicPairs, ); - const activeMedications = await prisma.medication.findMany({ + const activeMedicationRows = await prisma.medication.findMany({ // v1.16.11 — as-needed (PRN) medications never feed the BP-status // compliance gate (no expected doses, no rate). where: { userId, active: true, asNeeded: false }, @@ -339,6 +340,12 @@ export async function prepareBloodPressureStatusForUser( }, }); + // 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 activeMedications = activeMedicationRows.filter(expectsDoses); + const categoryMap = await getMedicationCategories( activeMedications.map((medication) => medication.id), ); diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 7e1be8512..1c695579e 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -288,7 +288,10 @@ export async function fetchCustomMetricBehaviourSeries( name: true, unit: true, entries: { - where: { measuredAt: { gte: since } }, + // The parent metric's tombstone is filtered above; the entries' own + // one has to be too, or a reading the person deleted keeps shaping + // the correlation they are shown. + where: { deletedAt: null, measuredAt: { gte: since } }, orderBy: [{ measuredAt: "desc" }, { id: "desc" }], take: 2000, select: { value: true, unit: true, measuredAt: true }, diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index f7528dc2e..17236dd2d 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -9,6 +9,7 @@ import { buildComplianceMedicationContext, buildMedicationComplianceBundle, dailyComplianceRatesFromLedger, + expectsDoses, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; @@ -208,7 +209,7 @@ export async function prepareMedicationComplianceStatusForUser( }; } - const medications = await prisma.medication.findMany({ + 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 }, @@ -224,6 +225,12 @@ export async function prepareMedicationComplianceStatusForUser( 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. + const medications = medicationRows.filter(expectsDoses); + if (medications.length === 0) { return { phase: "served", diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index f829df5f6..ed2c0a633 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -20,10 +20,11 @@ import { prisma } from "@/lib/db"; import { - pearsonCorrelation, + significantPearsonCorrelation, type CorrelationResult, type PairedPoint, } from "@/lib/analytics/correlations"; +import { MIN_PAIRED_N } from "@/lib/insights/correlations"; import { applyPayloadBudget, dayOffsetToBerlinDayKey, @@ -384,11 +385,20 @@ export interface CorrelationScatterPoint { [key: string]: number; } +/** Why a pairing produced no coefficient. */ +export type MoodCorrelationSuppression = "insufficientPairs" | "notSignificant"; + export interface MoodMetricCorrelation { result: CorrelationResult | null; /** Mood (x) vs metric (y) scatter points, paired by day. */ points: CorrelationScatterPoint[]; n: number; + /** + * Which bar the pairing fell short of, so the card can name it rather + * than showing a blank that reads the same whether nothing was logged or + * a full year of days simply did not line up. Absent when `result` is set. + */ + suppressed?: MoodCorrelationSuppression; } /** @@ -396,6 +406,14 @@ export interface MoodMetricCorrelation { * series, paired on `dayOffset`. Returns the scatter points so the * caller can paint a `` from the same pairing * the coefficient was computed over. + * + * This routes through `significantPearsonCorrelation` — n >= 20 AND + * p < 0.05 — for the same reason the weight, blood-pressure and + * mood-status surfaces do: the plain `pearsonCorrelation` bands from |r| + * alone above a floor of five pairs, so five days of noise sloping the + * right way arrive at the reader labelled "strong". A mood page is where + * that costs the most, because the reader is looking for something to + * change about their week. */ export function computeMoodMetricCorrelation( moodDaily: DailyPoint[], @@ -404,9 +422,16 @@ export function computeMoodMetricCorrelation( tz: string = DEFAULT_TIMEZONE, ): MoodMetricCorrelation { const pairs = pairDailyBuckets(moodDaily, metricDaily, now, tz); - const result = pearsonCorrelation(pairs); const points = pairs.map((p) => ({ x: p.a, y: p.b })); - return { result, points, n: pairs.length }; + const result = significantPearsonCorrelation(pairs); + if (result) return { result, points, n: pairs.length }; + return { + result: null, + points, + n: pairs.length, + suppressed: + pairs.length < MIN_PAIRED_N ? "insufficientPairs" : "notSignificant", + }; } // --- Orchestrated aggregate shape --- diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 6d6408d67..645b46724 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -324,7 +324,12 @@ export async function computeStatusInputFingerprint(args: { }), args.includeMood ? prisma.moodEntry.aggregate({ - where: { userId: args.userId }, + // Tombstoned rows are excluded here for the same reason they are in + // the measurement arm above: this aggregate IS the freshness + // fingerprint, so a deletion that leaves the count and the newest + // timestamp untouched cannot invalidate the cached assessment, and + // yesterday's text gets re-dated as today's over data that is gone. + where: { userId: args.userId, deletedAt: null }, _count: { _all: true }, _max: { moodLoggedAt: true }, }) @@ -342,8 +347,9 @@ export async function computeStatusInputFingerprint(args: { id: true, unit: true, updatedAt: true, - _count: { select: { entries: true } }, + _count: { select: { entries: { where: { deletedAt: null } } } }, entries: { + where: { deletedAt: null }, orderBy: [{ measuredAt: "desc" }, { id: "desc" }], take: 1, select: { measuredAt: true, unit: true }, diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index b6cefe5e1..f1e11411e 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -33,10 +33,12 @@ export function decryptContextFromBytes(buf: Uint8Array): string { /** Decrypt a stored `Bytes` context note, fail-soft to `null` on any error. * - * Mirrors `decryptNoteSoft` in the illness DTO layer: a single bad-key / - * malformed row must not 500 the whole catalog list. The single-resource GET - * path uses the throwing `decryptContextFromBytes` so a genuine decrypt - * failure there surfaces instead of silently masking a key-rotation gap. */ + * A single bad-key / malformed row must not 500 the whole catalog list, and + * the list surface renders a missing context as nothing either way. The + * single-resource GET path uses the throwing `decryptContextFromBytes` so a + * genuine decrypt failure there surfaces instead of silently masking a + * key-rotation gap, and the backup export reads through its own helper that + * marks the row rather than blanking it. */ export function decryptContextSoft(buf: Uint8Array | null): string | null { if (!buf) return null; try { diff --git a/src/lib/labs/store.ts b/src/lib/labs/store.ts index 11a18e45e..2a215fa16 100644 --- a/src/lib/labs/store.ts +++ b/src/lib/labs/store.ts @@ -5,11 +5,15 @@ * free-text note is the only sensitive column on the model; it shares the * `encrypt()` string format (`"."`) every other `*Encrypted` * column uses, encoded as UTF-8 bytes. + * + * There is one decrypt here and it throws. A fail-soft twin used to sit + * beside it for bulk reads; the list DTO never carries the note (it reports + * `hasNote` only) and the export now writes a visible marker instead of a + * null, so nothing was left that wanted a silent one. */ import { Buffer } from "node:buffer"; import { decrypt, encrypt } from "@/lib/crypto"; -import { getEvent } from "@/lib/logging/context"; /** Encrypt a UTF-8 note into the `Bytes` payload the schema stores. */ export function encryptNoteToBytes(plaintext: string): Uint8Array { @@ -26,26 +30,3 @@ export function encryptNoteToBytes(plaintext: string): Uint8Array { export function decryptNoteFromBytes(buf: Uint8Array): string { return decrypt(Buffer.from(buf).toString("utf8")); } - -/** - * Decrypt a stored `Bytes` note, fail-soft to `null` on any error. - * - * Mirrors `decryptContextSoft` in the biomarker store: a single bad-key / - * malformed row must not fail a bulk read (list view, backup export) that - * spans many rows. The single-resource GET path uses the throwing - * `decryptNoteFromBytes` so a genuine decrypt failure there surfaces instead - * of silently masking a key-rotation gap. - */ -export function decryptNoteSoft(buf: Uint8Array | null): string | null { - if (!buf || buf.byteLength === 0) return null; - try { - return decryptNoteFromBytes(buf); - } catch (err) { - getEvent()?.addWarning( - `lab note decrypt failed: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - return null; - } -} diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index a6daaf312..2d22ba3bf 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -63,6 +63,9 @@ import { encryptWaveformToBytes, } from "@/lib/withings/ecg-waveform-codec"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; +import { decryptNoteFromBytes } from "@/lib/labs/store"; +import { decryptContextFromBytes } from "@/lib/labs/biomarker-store"; import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan"; import { POST } from "@/app/api/admin/backups/[id]/restore/route"; @@ -2619,4 +2622,103 @@ describe("every model the plan claims two-ended survives a real restore", () => }, }); }); + + /** + * A note this instance can no longer decrypt leaves the export as a marker, + * not as nothing. + * + * A fail-soft decrypt answers `null` on a wrong or dropped key, and in a + * portable file `null` is byte-for-byte what a note that was never written + * looks like. The restore importer then writes no note, so a note the person + * did write is gone and no one is told — the whole loss fits inside a + * fail-soft `catch` written for a list view, where returning null is right. + * + * The Coach transcript and the doctor report both refuse that trade, one + * with a placeholder sentence and one with a rendered flag. This asserts the + * lab note now does the same, and that the marker is still there after the + * file has been through a real restore: the ciphertext is unreadable either + * way, but a person opening the restored account can see that something was + * there rather than believing they never wrote it. + */ + it("carries a lab note it cannot decrypt through the round trip as a visible marker", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + + const biomarker = await prisma.biomarker.create({ + data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" }, + }); + // Ciphertext under a key id this instance does not hold — the shape a row + // written before a key rotation that dropped its key has. `decrypt` is + // fail-closed on an unknown key id, so this throws rather than returning + // garbage, which is the case the soft helper swallowed. + const unreadable = new Uint8Array( + Buffer.from("v9.AAAAAAAAAAAAAAAAAAAAAAAA", "utf8"), + ); + await prisma.labResult.create({ + data: { + userId: OWNER_ID, + biomarkerId: biomarker.id, + analyte: "Ferritin", + value: 91, + unit: "ng/mL", + takenAt: AT("2026-06-30T09:00:00.000Z"), + noteEncrypted: unreadable, + }, + }); + await prisma.biomarker.update({ + where: { id: biomarker.id }, + data: { contextEncrypted: unreadable }, + }); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "portable-export", + }); + + const exported = payload.labResults as Array<{ note: string | null }>; + expect(exported).toHaveLength(1); + expect(exported[0].note).toContain("unreadable"); + const exportedBiomarkers = payload.biomarkers as Array<{ + context: string | null; + }>; + expect(exportedBiomarkers[0].context).toContain("unreadable"); + + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "TWO_ENDED_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + + const restoredLab = await prisma.labResult.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + // Back in the column it came from, readable again because the restore + // re-encrypted it under the CURRENT key — and saying what happened. + expect(restoredLab.noteEncrypted).not.toBeNull(); + expect(decryptNoteFromBytes(restoredLab.noteEncrypted!)).toBe( + UNREADABLE_EXPORT_MARKER, + ); + + const restoredBiomarker = await prisma.biomarker.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect(decryptContextFromBytes(restoredBiomarker.contextEncrypted!)).toBe( + UNREADABLE_EXPORT_MARKER, + ); + }); });