mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 23:12:04 +00:00
Merge fix/guarded-twins
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
ee4afc7b9e
33 files changed
+1164
-73
No files matched your search
@@ -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[],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user