Merge fix/guarded-twins

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 03:23:40 +02:00
commit ee4afc7b9e
33 files changed
+1164 -73

No files matched your search

+45
View File
@@ -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
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -3708,6 +3708,9 @@
"sourceLabel": "계산 방법",
"emptyTitle": "아직 짝지을 데이터가 부족해요",
"emptyDescription": "같은 날에 기분과 이 지표를 함께 기록하면 상관관계를 볼 수 있어요.",
"pendingDescription": "짝지은 {n}일 / {needed}일. 우연과 패턴을 구분할 만큼 날이 쌓이면 상관관계가 나타나요.",
"noLinkTitle": "뚜렷한 관계 없음",
"noLinkDescription": "짝지은 {n}일 동안 우연으로 볼 수 있는 범위를 넘는 건 없었어요.",
"weightTitle": "기분 대 체중",
"bloodPressureTitle": "기분 대 혈압",
"weightAxis": "체중",
+3
View File
@@ -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",
@@ -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<string, string> = {
"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 = /(?<![A-Za-z])pearsonCorrelation\s*\(/;
describe("the unguarded Pearson helper has a closed caller set", () => {
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([]);
});
});
@@ -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<typeof vi.fn>).mockResolvedValue(
EMPTY_AGGREGATE,
);
(prisma.medication.findMany as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(
EMPTY_AGGREGATE,
);
(prisma.medication.findMany as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([
MEDICATION,
]);
(
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
).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,
});
});
});
+18 -2
View File
@@ -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<string, number>();
// 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<string>();
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({
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
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(
<MoodCorrelationCards
sleep={sleep}
steps={NOTHING}
pulse={NOTHING}
weight={NOTHING}
bloodPressureSystolic={NOTHING}
/>,
);
const start = html.indexOf('data-kind="sleep"');
const end = html.indexOf('data-kind="steps"');
return html.slice(start, end);
}
describe("<MoodCorrelationCards>", () => {
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");
});
});
@@ -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<MetricKind, string> = {
@@ -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 (
<Card
@@ -168,8 +205,8 @@ function MoodCorrelationCard({
variant="plain"
size="compact"
icon={<Activity className="size-5" />}
title={t("insights.mood.correlation.emptyTitle")}
description={t("insights.mood.correlation.emptyDescription")}
title={emptyCopy.title}
description={emptyCopy.description}
/>
)}
</CardContent>
+22
View File
@@ -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[],
+4 -3
View File
@@ -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<string, DoctorReportCompliance> = {};
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(
@@ -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);
});
});
+2 -1
View File
@@ -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;
}
}
+52 -7
View File
@@ -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
+19
View File
@@ -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]";
+7 -2
View File
@@ -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(),
@@ -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");
});
});
@@ -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();
});
});
@@ -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 },
@@ -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");
});
});
@@ -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");
});
});
+8 -1
View File
@@ -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),
);
@@ -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 },
@@ -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",
+28 -3
View File
@@ -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 `<ScatterCorrelationChart>` 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 ---
+8 -2
View File
@@ -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 },
+6 -4
View File
@@ -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 {
+5 -24
View File
@@ -5,11 +5,15 @@
* free-text note is the only sensitive column on the model; it shares the
* `encrypt()` string format (`"<keyId>.<base64>"`) 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<ArrayBuffer> {
@@ -26,26 +30,3 @@ export function encryptNoteToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
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;
}
}
+102
View File
@@ -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,
);
});
});