From f72e236b8a64ab74771de2e195afde2ac182c940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:07:29 +0200 Subject: [PATCH 1/8] fix(insights): gate the mood correlation cards on significance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mood page called the plain `pearsonCorrelation` — strength banded from |r| alone above a floor of five pairs, no significance test — so five days of noise sloping the right way arrived at the reader as a "Strong" badge, with n and r behind an info icon. The guarded twin was written for exactly that failure and names it in its own comment, and the weight, blood-pressure and mood-status surfaces had already moved onto it. The mood page had not. Route the page's five cross-metric channels through `significantPearsonCorrelation` (n >= 20 AND p < 0.05) and carry the reason a pairing was refused, so the card can distinguish "still building" from "twenty days and nothing in them" instead of showing the same blank for both. New copy in all seven locales. Add a structural guard that fails when a new caller of the unguarded helper appears outside a written allowlist. Two callers remain on it, each named as open work rather than as a blessing: the /insights envelope and the AI feature payload both still band at n >= 5, and moving them changes payload shapes their own tests pin. --- messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/ko.json | 3 + messages/pl.json | 3 + .../unguarded-correlation-guard.test.ts | 117 ++++++++++++++++++ .../__tests__/mood-correlation-cards.test.tsx | 104 ++++++++++++++++ .../insights/mood/mood-correlation-cards.tsx | 45 ++++++- .../__tests__/mood-aggregates.test.ts | 36 ++++-- ...od-metric-correlation-significance.test.ts | 78 ++++++++++++ src/lib/insights/mood-aggregates.ts | 31 ++++- 13 files changed, 416 insertions(+), 16 deletions(-) create mode 100644 src/__tests__/unguarded-correlation-guard.test.ts create mode 100644 src/components/insights/mood/__tests__/mood-correlation-cards.test.tsx create mode 100644 src/lib/insights/__tests__/mood-metric-correlation-significance.test.ts 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/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/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/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 --- From 04361a9fe7f2a953e8064206794541c0a8e46286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:07:43 +0200 Subject: [PATCH 2/8] fix(medications): drop a medication with no schedule from every adherence surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `calculateCompliance` answers `rate: 100, totalExpected: 0` for a medication that expects no dose. Arithmetically honest, and a lie on a surface that reads the number as adherence — it also inflated the averages the AI prompts are built from. The doctor report has excluded both the PRN arm and the empty-schedule arm since it was written, with the reason beside it. The insights envelope, the blood-pressure gate and the medication-compliance snapshot filtered only PRN, and the empty schedule is reachable: `POST /api/medications` accepts `schedules ?? []` on a scheduled medication. Move both arms into one predicate, `expectsDoses`, and route all four surfaces through it so they cannot drift apart again. --- src/app/api/insights/comprehensive/route.ts | 9 +- src/lib/analytics/compliance/display.ts | 22 +++++ src/lib/doctor-report-helpers.ts | 7 +- .../__tests__/blood-pressure-status.test.ts | 73 +++++++++++++++++ .../medication-compliance-status.test.ts | 82 +++++++++++++++++++ src/lib/insights/blood-pressure-status.ts | 9 +- .../insights/medication-compliance-status.ts | 9 +- 7 files changed, 205 insertions(+), 6 deletions(-) diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index a3cf6e902..d73ba9d6f 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), ); 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/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/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/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", From c2c018aa08b9f05c7065314e20b1bf852f0586d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:07:52 +0200 Subject: [PATCH 3/8] fix(insights): exclude unlogged days from the medication-continuity pairing The blood-pressure medication continuity series joined every day that had a systolic reading against `takenByDay.get(day) ?? 0`, so a day with no intake record at all entered the scatter and the coefficient as a measured 0 % continuity. A week away from the app read as a week off the medication. Only days carrying an intake record are paired now. A logged skip is a real zero and stays; a day with no record is absent, and absence is not zero. The timezone-parity fixture in the route test recorded three logged days out of five and relied on the other two arriving as zeros, which pinned the defect rather than the timezone question it asks. Its two remaining days are recorded as skips, which keeps five paired days and keeps every one of them something the person actually logged. --- .../comprehensive/__tests__/route.test.ts | 239 +++++++++++++++++- src/app/api/insights/comprehensive/route.ts | 11 +- 2 files changed, 244 insertions(+), 6 deletions(-) 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 d73ba9d6f..a063ea1c5 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -504,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({ From 4d98a273b1cd10f0806a306996dcb68073e6ec9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:08:09 +0200 Subject: [PATCH 4/8] fix(export): carry an undecryptable note as a marker instead of as nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lab note or a biomarker context written under a key this instance no longer holds came out of a portable export as `null` — byte-identical to a column that was never written. The restore importer then wrote nothing, so the text was gone and nothing said it had ever been there. The whole loss fit inside a fail-soft `catch` written for a list view, where returning null is the right answer. Both now export a short marker naming what happened, and the marker survives a restore. The Coach transcript already did this with a placeholder sentence and the doctor report with a rendered flag; the marker is now one constant the transcript shares, and its comment says why an empty string will not do. The unit test that required `note: null` here pinned the loss. It keeps the half that matters — one bad row must never abort the export — and asserts the marker instead. The round trip is proven end to end against a real restore. --- .../export/__tests__/records-backup.test.ts | 13 ++- src/lib/export/coach-backup.ts | 3 +- src/lib/export/records-backup.ts | 59 ++++++++-- src/lib/export/unreadable-marker.ts | 19 ++++ tests/integration/backup-round-trip.test.ts | 102 ++++++++++++++++++ 5 files changed, 184 insertions(+), 12 deletions(-) create mode 100644 src/lib/export/unreadable-marker.ts 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..383212b0c 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. + * + * `decryptNoteSoft` / `decryptContextSoft` answer `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/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index a6daaf312..aba427cd9 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. + * + * `decryptNoteSoft` 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, + ); + }); }); From 156f7e1523353c3ac083d426958e378a93419f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:08:09 +0200 Subject: [PATCH 5/8] fix(insights): skip tombstoned rows in the correlation channel and the cache gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deleted reading is kept with a `deletedAt` stamp, and the schema says in as many words that normal reads filter it. Three did not. The custom-metric correlation channel filtered the metric's own tombstone but not its entries', so a reading the person deleted kept shaping the correlation they were shown. The status input hash — which decides whether a cached AI assessment is still current — filtered its measurement arm but not its mood aggregate and not its custom-metric count or newest-entry probe. A deletion left that fingerprint unchanged, so the gate re-dated yesterday's text and served it as today's over data that was gone. Pinned in the existing soft-delete guard, whose matchers were checked by removing each filter again. --- .../prompt-soft-delete-filter.test.ts | 40 +++++++++++++++++++ .../insights/correlation-channel-series.ts | 5 ++- src/lib/insights/status-cache.ts | 10 ++++- 3 files changed, 52 insertions(+), 3 deletions(-) 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/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/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 }, From ae3525a88e255318c84b49fe1b2711e21a863f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:08:10 +0200 Subject: [PATCH 6/8] refactor(fhir): skip an adherence observation with no expectation rather than write 0 `adherenceRatePercent` returns null exactly when nothing was expected, so callers render a placeholder instead of a misleading 0 or 100. The FHIR exporter coalesced that null to 0, which would put a clinical claim of zero adherence into an exported bundle. The guard above it means the branch cannot be reached today; skipping rather than coalescing keeps it unreachable if that guard ever moves. --- src/lib/fhir/resources/observation.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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(), From ae731b92d91e312930e94a0b1fa79ac9e0e24738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:08:10 +0200 Subject: [PATCH 7/8] docs(changelog): record the correlation, adherence, continuity and export fixes --- CHANGELOG.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48b6ec02c..2cd992bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +## [Unreleased] + +### 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. + ## [1.38.4] — 2026-09-02 The shared AI key reaches the provider its address names, and the image From 83b1400e5bbedd16c1197b5faaaf5bafdccd66d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:29:37 +0200 Subject: [PATCH 8/8] refactor(labs): drop the fail-soft lab-note decrypt, which has no caller left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export was its only user, and the export now reads through a helper that marks an undecryptable note instead of blanking it. Nothing else wanted the silent null: the list DTO never carries the note at all, only `hasNote`, and the single-resource GET has always used the throwing decrypt so a key gap surfaces there. Deleted rather than kept behind a dead-code exemption. A fail-soft twin sitting unused next to a strict one is the exact shape this branch has been clearing out — the next person writing an export reaches for whatever is nearest, and the whole point was that the nearest thing should not silently swallow a row. It had no test of its own, so none was removed; the three comments that pointed at it are rewritten to say what is true now. --- src/lib/export/records-backup.ts | 14 +++++----- src/lib/labs/biomarker-store.ts | 10 ++++--- src/lib/labs/store.ts | 29 ++++----------------- tests/integration/backup-round-trip.test.ts | 2 +- 4 files changed, 19 insertions(+), 36 deletions(-) diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index 383212b0c..2948689e8 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -50,13 +50,13 @@ export const WORKOUTS_MANIFEST_NOTE = * Decrypt an encrypted free-text column for a portable export, or say plainly * that this one could not be read. * - * `decryptNoteSoft` / `decryptContextSoft` answer `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. + * 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. 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 aba427cd9..2d22ba3bf 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -2627,7 +2627,7 @@ 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. * - * `decryptNoteSoft` answers `null` on a wrong or dropped key, and in a + * 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