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

+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;
}
}