mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge v1.25.1: deep data/cross-cut (cadence-aware alert compliance, lab-unit guard, signals in briefing, biomarker fallback, cleanups)
This commit is contained in:
commit
2ec4828c1f
21 files changed
+509
-80
No files matched your search
@@ -164,4 +164,68 @@ describe("GET /api/insights/cards", () => {
|
||||
expect(body.data[0].provider).toBe("anthropic");
|
||||
expect(body.data.some((c) => c.severity === "alert")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT flag a fully-adherent weekly injectable as low compliance (#214 regression)", async () => {
|
||||
// Pre-fix this path used a naive `schedules.length × 7` denominator, so a
|
||||
// weekly Monday injectable with its one dose taken computed ~14% and fired
|
||||
// a false "Low compliance" warning. The cadence-aware engine counts only
|
||||
// the Monday in the window → 100%, no alert.
|
||||
vi.useFakeTimers();
|
||||
// Pin NOW to a Wednesday so the trailing 7-day window holds exactly one
|
||||
// Monday (the prior 2025-01-13).
|
||||
vi.setSystemTime(new Date("2025-01-15T12:00:00Z"));
|
||||
try {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-weekly",
|
||||
name: "Weekly injectable",
|
||||
dose: null,
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
oneShot: false,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
createdAt: new Date("2024-06-01T00:00:00Z"),
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-mon",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "10:00",
|
||||
daysOfWeek: "1",
|
||||
timesOfDay: ["08:30"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: "FREQ=WEEKLY;BYDAY=MO",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
medicationId: "med-weekly",
|
||||
scheduledFor: new Date("2025-01-13T08:30:00Z"),
|
||||
takenAt: new Date("2025-01-13T08:35:00Z"),
|
||||
skipped: false,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{ title: string }>;
|
||||
};
|
||||
expect(body.data.some((c) => c.title.startsWith("Low compliance"))).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
type HealthAlert,
|
||||
} from "@/lib/analytics/classifications";
|
||||
import { pairByTimestamp } from "@/lib/analytics/correlations";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
type Severity = "alert" | "caution" | "info" | "good";
|
||||
@@ -86,11 +92,17 @@ export const GET = apiHandler(async () => {
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId: user.id, active: true },
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId: user.id, active: true, asNeeded: false },
|
||||
include: {
|
||||
schedules: true,
|
||||
// Schedules through the shared compliance select so the configured
|
||||
// per-dose windows reach the engine like every other surface.
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware expected counts.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
@@ -135,7 +147,12 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight medication compliance for alert input.
|
||||
// Medication compliance for alert input — routed through the canonical
|
||||
// cadence-aware engine (`calculateCompliance`), the same single source of
|
||||
// truth the comprehensive insights, dashboard pillar, Coach snapshot and
|
||||
// doctor report use. The naive `schedules.length × days` denominator was a
|
||||
// #214 regression that falsely flagged weekly injectables and paused meds
|
||||
// as low-adherence on this user-facing alert path.
|
||||
const medicationCompliance: Array<{
|
||||
name: string;
|
||||
compliance7: number;
|
||||
@@ -150,8 +167,11 @@ export const GET = apiHandler(async () => {
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
deletedAt: null,
|
||||
medicationId: { in: activeMeds.map((m) => m.id) },
|
||||
scheduledFor: { gte: new Date(Date.now() - 30 * 86_400_000) },
|
||||
// 90-day window: the rolling-cadence gap-walk re-anchors on prior
|
||||
// intakes outside the 30-day rate window.
|
||||
scheduledFor: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
select: {
|
||||
medicationId: true,
|
||||
takenAt: true,
|
||||
@@ -165,26 +185,36 @@ export const GET = apiHandler(async () => {
|
||||
arr.push(e);
|
||||
eventsByMed.set(e.medicationId, arr);
|
||||
}
|
||||
const cutoff7 = Date.now() - 7 * 86_400_000;
|
||||
const tz = user.timezone || "Europe/Berlin";
|
||||
for (const med of activeMeds) {
|
||||
const events = eventsByMed.get(med.id) ?? [];
|
||||
const takenLast7 = events.filter(
|
||||
(e) =>
|
||||
e.takenAt !== null &&
|
||||
!e.skipped &&
|
||||
e.scheduledFor.getTime() >= cutoff7,
|
||||
).length;
|
||||
const taken30 = events.filter(
|
||||
(e) => e.takenAt !== null && !e.skipped,
|
||||
).length;
|
||||
const expected7 = med.schedules.length * 7;
|
||||
const expected30 = med.schedules.length * 30;
|
||||
const mapped = events.map((e) => ({
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
scheduledFor: e.scheduledFor,
|
||||
}));
|
||||
// v1.7.0 SB-SCHED-2 — engine-routed denominator.
|
||||
const medicationContext = buildComplianceMedicationContext(
|
||||
med,
|
||||
lastNonSkippedTakenAt(mapped),
|
||||
tz,
|
||||
);
|
||||
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, {
|
||||
medicationContext,
|
||||
});
|
||||
const c30 = calculateCompliance(
|
||||
mapped,
|
||||
med.schedules,
|
||||
30,
|
||||
med.createdAt,
|
||||
{
|
||||
medicationContext,
|
||||
},
|
||||
);
|
||||
medicationCompliance.push({
|
||||
name: med.name,
|
||||
compliance7:
|
||||
expected7 > 0 ? Math.round((takenLast7 / expected7) * 100) : 0,
|
||||
compliance30:
|
||||
expected30 > 0 ? Math.round((taken30 / expected30) * 100) : 0,
|
||||
compliance7: c7.rate,
|
||||
compliance30: c30.rate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,23 +15,15 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* The copy lives under the `selfDescription` i18n namespace so every locale
|
||||
* carries the same set; pick the note that fits the surface:
|
||||
*
|
||||
* - `intendedPurpose` — the canonical "what this is / is not, not a medical
|
||||
* device" line. Reuse verbatim; do not paraphrase per surface.
|
||||
* - `coach` — the Coach standing line (works from your data, not a doctor).
|
||||
* - `emergency` — the red-flag interstitial note (contact emergency services).
|
||||
* - `dataPosture` — self-hosted, data stays on your instance, BYOK / local AI.
|
||||
*
|
||||
* The acknowledged onboarding disclaimer (`onboarding.disclaimer.*`) and the
|
||||
* legal medical-device boundary on the public privacy page remain the standing
|
||||
* floor; these notes are the gentle, in-context layer above it.
|
||||
* floor; this note is the gentle, in-context layer above it.
|
||||
*/
|
||||
export type DisclaimerVariant =
|
||||
"intendedPurpose" | "coach" | "emergency" | "dataPosture";
|
||||
export type DisclaimerVariant = "dataPosture";
|
||||
|
||||
const VARIANT_KEY: Record<DisclaimerVariant, string> = {
|
||||
intendedPurpose: "selfDescription.intendedPurpose",
|
||||
coach: "selfDescription.coach",
|
||||
emergency: "selfDescription.emergency",
|
||||
dataPosture: "selfDescription.dataPosture",
|
||||
};
|
||||
|
||||
|
||||
@@ -111,7 +111,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
const [lowerInput, setLowerInput] = useState("");
|
||||
const [upperInput, setUpperInput] = useState("");
|
||||
|
||||
const { data: marker, isError: markerError } = useQuery({
|
||||
const {
|
||||
data: marker,
|
||||
isError: markerError,
|
||||
refetch: refetchMarker,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.biomarkerDetail(biomarkerId),
|
||||
queryFn: () => apiGet<BiomarkerDto>(`/api/biomarkers/${biomarkerId}`),
|
||||
});
|
||||
@@ -125,6 +129,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
data: list,
|
||||
isLoading,
|
||||
isError: listError,
|
||||
refetch: refetchList,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }),
|
||||
initialPageParam: 0,
|
||||
@@ -239,11 +244,50 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
onError: () => toast.error(t("labs.biomarker.targetRange.saveError")),
|
||||
});
|
||||
|
||||
// v1.25.1 — the per-marker description leads the page, mirroring the metric
|
||||
// sub-pages' explainer caption. Resolve the catalog slug from the marker name
|
||||
// and fall back to the user's own `context`, then to a generic explainer so a
|
||||
// catalog-less custom marker never renders an empty description block (parity
|
||||
// with the insights metric pages, which always carry one).
|
||||
const catalogSlug = marker?.name
|
||||
? slugByName.get(marker.name.trim().toLowerCase())
|
||||
: undefined;
|
||||
const description = catalogSlug
|
||||
? t(`labs.catalog.desc.${catalogSlug}`)
|
||||
: marker?.context?.trim()
|
||||
? marker.context.trim()
|
||||
: t("labs.detail.genericDescription");
|
||||
|
||||
// v1.25.1 (H2-M2) — the error state keeps the heading + description and adds a
|
||||
// retry, mirroring the insights `SubPageShell` recovery affordance rather than
|
||||
// collapsing to a bare line with no context and no way back.
|
||||
if (markerError || listError) {
|
||||
return (
|
||||
<p className="text-destructive py-8 text-center text-sm">
|
||||
{t("labs.loadError")}
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-1.5">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{marker?.name ?? t("labs.detail.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</header>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p className="text-destructive text-sm">{t("labs.loadError")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (markerError) void refetchMarker();
|
||||
if (listError) void refetchList();
|
||||
}}
|
||||
>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -330,22 +374,12 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
</div>
|
||||
|
||||
{/* v1.25.1 — the static description leads the page, directly beneath the
|
||||
heading, mirroring the metric sub-pages' explainer caption. Resolves
|
||||
the catalog slug from the marker name, falling back to the user's own
|
||||
`context`. */}
|
||||
{(() => {
|
||||
const slug = marker?.name
|
||||
? slugByName.get(marker.name.trim().toLowerCase())
|
||||
: undefined;
|
||||
const description = slug
|
||||
? t(`labs.catalog.desc.${slug}`)
|
||||
: (marker?.context ?? null);
|
||||
return description ? (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
) : null;
|
||||
})()}
|
||||
heading, mirroring the metric sub-pages' explainer caption. Computed
|
||||
once above (catalog desc → user `context` → generic fallback) so a
|
||||
catalog-less marker still carries a description. */}
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
|
||||
@@ -703,6 +703,7 @@ export function MedicationDetailTabs({
|
||||
label={t("medications.detail.erweitert.group.externalApi")}
|
||||
dataSlot="api-group-external-api"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
>
|
||||
<div className="py-3">
|
||||
<ApiTokensRow
|
||||
@@ -727,6 +728,7 @@ export function MedicationDetailTabs({
|
||||
label={t("medications.detail.erweitert.group.lifecycle")}
|
||||
dataSlot="erweitert-group-lifecycle"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
>
|
||||
<div className="py-3">
|
||||
<LifecycleManageBody
|
||||
|
||||
@@ -301,7 +301,10 @@ export function MentalWellbeing() {
|
||||
|
||||
{result.crisis && (
|
||||
<Card
|
||||
className="border-amber-400/50"
|
||||
// v1.25.1 — calm destructive-subtle border instead of the amber
|
||||
// tint v1.25 retired; the role/aria-live carry the urgency, the
|
||||
// tone stays consistent with the rest of the app.
|
||||
className="border-destructive/40"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
|
||||
@@ -153,6 +153,29 @@ function authoritativeValues(
|
||||
push(w.latest.daysAgo);
|
||||
}
|
||||
}
|
||||
// v1.25.1 — clinical-depth aggregate blocks (grip / waist / pain) wired
|
||||
// into the briefing. Admit their pre-computed figures so the prose may cite
|
||||
// them, same posture as the glucose / labs / workout blocks above.
|
||||
const grip = features.gripStrength;
|
||||
if (grip) {
|
||||
push(grip.latest);
|
||||
push(grip.avg30);
|
||||
push(grip.slope30);
|
||||
}
|
||||
const waist = features.waist;
|
||||
if (waist) {
|
||||
push(waist.latest);
|
||||
push(waist.avg30);
|
||||
push(waist.slope30);
|
||||
push(waist.whtrLatest);
|
||||
}
|
||||
const pain = features.pain;
|
||||
if (pain) {
|
||||
push(pain.latest);
|
||||
push(pain.avg7);
|
||||
push(pain.avg30);
|
||||
push(pain.slope30);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* v1.25.1 — commit-path unit-integrity tests.
|
||||
*
|
||||
* Committing a numeric observation against an EXISTING biomarker must not
|
||||
* silently rewrite the document's stated unit with the catalog's. When the
|
||||
* units disagree the fact is rejected (fail closed) so the user reconciles on
|
||||
* the review screen, exactly like the `unitRequired` guard — never a corrupt
|
||||
* "6.1 mmol/L written as 6.1 mg/dL" reading.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
labResult: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/labs/biomarker-store", () => ({
|
||||
resolveOrMintBiomarker: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
decryptFactData: vi.fn(),
|
||||
}));
|
||||
|
||||
import { commitApprovedFact, FactCommitError } from "@/lib/documents/commit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { decryptFactData } from "@/lib/documents/store";
|
||||
import type { ExtractedFact } from "@/generated/prisma/client";
|
||||
|
||||
function observationFact(): ExtractedFact {
|
||||
return {
|
||||
id: "fact-1",
|
||||
factType: "OBSERVATION",
|
||||
dataEncrypted: new Uint8Array(),
|
||||
} as unknown as ExtractedFact;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("commitApprovedFact — OBSERVATION unit integrity", () => {
|
||||
it("rejects a numeric observation whose stated unit differs from an existing marker's unit", async () => {
|
||||
vi.mocked(decryptFactData).mockReturnValue({
|
||||
label: "Glucose",
|
||||
value: 6.1,
|
||||
unit: "mmol/L",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
effectiveDate: "2025-01-01",
|
||||
} as never);
|
||||
// Existing marker stored in mg/dL — the resolver returns it as-is.
|
||||
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
||||
id: "bm-1",
|
||||
name: "Glucose",
|
||||
unit: "mg/dL",
|
||||
lowerBound: 70,
|
||||
upperBound: 100,
|
||||
panel: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
commitApprovedFact("user-1", observationFact()),
|
||||
).rejects.toMatchObject({ code: "observation.unitMismatch" });
|
||||
await expect(
|
||||
commitApprovedFact("user-1", observationFact()),
|
||||
).rejects.toBeInstanceOf(FactCommitError);
|
||||
expect(prisma.labResult.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits when the stated unit matches the marker's unit (case/space-insensitive)", async () => {
|
||||
vi.mocked(decryptFactData).mockReturnValue({
|
||||
label: "Glucose",
|
||||
value: 95,
|
||||
unit: " mg/DL ",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
effectiveDate: "2025-01-01",
|
||||
} as never);
|
||||
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
||||
id: "bm-1",
|
||||
name: "Glucose",
|
||||
unit: "mg/dL",
|
||||
lowerBound: 70,
|
||||
upperBound: 100,
|
||||
panel: null,
|
||||
});
|
||||
vi.mocked(prisma.labResult.create).mockResolvedValue({
|
||||
id: "lr-1",
|
||||
} as never);
|
||||
|
||||
const ref = await commitApprovedFact("user-1", observationFact());
|
||||
expect(ref).toEqual({ recordType: "labResult", recordId: "lr-1" });
|
||||
expect(prisma.labResult.create).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,23 @@ async function commitObservation(
|
||||
referenceHigh: isQualitative ? null : data.referenceHigh,
|
||||
panel: null,
|
||||
});
|
||||
// Fail closed on a unit mismatch against an EXISTING marker. A freshly
|
||||
// minted marker adopts the document's unit, so this only bites when the
|
||||
// stated unit disagrees with the catalog's authoritative one (e.g. a
|
||||
// document states "6.1 mmol/L" against a marker stored in mg/dL). Writing
|
||||
// the stated number under the catalog's unit would corrupt the reading AND
|
||||
// break the "transcribe verbatim" contract — so reject the fact and let the
|
||||
// user reconcile on the review screen, exactly like the `unitRequired` gate.
|
||||
if (!isQualitative) {
|
||||
const stated = (data.unit as string).trim().toLowerCase();
|
||||
const target = biomarker.unit.trim().toLowerCase();
|
||||
if (stated !== target) {
|
||||
throw new FactCommitError(
|
||||
"observation.unitMismatch",
|
||||
`The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const created = await prisma.labResult.create({
|
||||
data: {
|
||||
userId,
|
||||
|
||||
@@ -270,6 +270,51 @@ describe("extractFeatures — v1.22 glucose aggregate block", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — v1.25.1 clinical-depth aggregate blocks", () => {
|
||||
it("emits grip, waist (with WHtR) and pain blocks from their readings", async () => {
|
||||
prismaMock.measurement.findMany.mockResolvedValue([
|
||||
measurementRow("GRIP_STRENGTH", 42, 1),
|
||||
measurementRow("GRIP_STRENGTH", 40, 20),
|
||||
measurementRow("WAIST_CIRCUMFERENCE", 90, 2),
|
||||
measurementRow("WAIST_CIRCUMFERENCE", 92, 25),
|
||||
measurementRow("PAIN_NRS", 4, 1),
|
||||
measurementRow("PAIN_NRS", 2, 10),
|
||||
]);
|
||||
const f = await extractFeatures("user-1", false);
|
||||
|
||||
expect(f.gripStrength).toBeDefined();
|
||||
expect(f.gripStrength?.latest).toBe(42);
|
||||
|
||||
expect(f.waist).toBeDefined();
|
||||
expect(f.waist?.latest).toBe(90);
|
||||
// heightCm is 180 in the default user mock → WHtR = 90 / 180 = 0.5.
|
||||
expect(f.waist?.whtrLatest).toBe(0.5);
|
||||
|
||||
expect(f.pain).toBeDefined();
|
||||
expect(f.pain?.latest).toBe(4);
|
||||
});
|
||||
|
||||
it("omits the clinical-depth blocks when there are no readings", async () => {
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.gripStrength).toBeUndefined();
|
||||
expect(f.waist).toBeUndefined();
|
||||
expect(f.pain).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves WHtR null when height is unknown", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
heightCm: null,
|
||||
dateOfBirth: new Date("1980-01-01"),
|
||||
gender: "MALE",
|
||||
});
|
||||
prismaMock.measurement.findMany.mockResolvedValue([
|
||||
measurementRow("WAIST_CIRCUMFERENCE", 90, 2),
|
||||
]);
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.waist?.whtrLatest).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — v1.22 labs briefing block", () => {
|
||||
it("surfaces an abnormal (out-of-range) biomarker", async () => {
|
||||
prismaMock.labResult.findMany.mockResolvedValue([
|
||||
|
||||
@@ -97,6 +97,45 @@ export interface AggregatedFeatures {
|
||||
slope30: number | null;
|
||||
coverage: DataCoverage;
|
||||
};
|
||||
/**
|
||||
* v1.25.1 — grip-strength aggregate (kg). A clinical-depth signal captured
|
||||
* and shown on its own detail page with EWGSOP2 sarcopenia bands; this is its
|
||||
* present-and-trend block so the daily briefing can narrate the trajectory.
|
||||
* The sex-aware floor stays at the display edge (`norms.ts`); the briefing
|
||||
* reads the trend. Omitted when no grip readings exist. PHQ-9/GAD-7 stay
|
||||
* excluded — only physical signals reach the narrative.
|
||||
*/
|
||||
gripStrength?: {
|
||||
latest: number | null;
|
||||
avg30: number | null;
|
||||
slope30: number | null;
|
||||
coverage: DataCoverage;
|
||||
};
|
||||
/**
|
||||
* v1.25.1 — waist aggregate. `latest`/`avg30` are the circumference (cm);
|
||||
* `whtrLatest` is the waist-to-height ratio (NICE keeps it < 0.5), computed
|
||||
* from the freshest circumference and the user's height. Omitted when no
|
||||
* waist readings exist.
|
||||
*/
|
||||
waist?: {
|
||||
latest: number | null;
|
||||
avg30: number | null;
|
||||
slope30: number | null;
|
||||
whtrLatest: number | null;
|
||||
coverage: DataCoverage;
|
||||
};
|
||||
/**
|
||||
* v1.25.1 — pain aggregate (0–10 NRS, lower-better). Present-and-trend block
|
||||
* so the briefing can flag a sustained or rising pain burden. Omitted when no
|
||||
* pain readings exist.
|
||||
*/
|
||||
pain?: {
|
||||
latest: number | null;
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
slope30: number | null;
|
||||
coverage: DataCoverage;
|
||||
};
|
||||
mood?: {
|
||||
scale: string;
|
||||
avg7: number | null;
|
||||
@@ -1292,6 +1331,51 @@ export async function extractFeatures(
|
||||
};
|
||||
}
|
||||
|
||||
// Grip strength (kg) — clinical-depth signal; the briefing narrates the
|
||||
// trajectory, the sex-aware EWGSOP2 floor stays at the display edge.
|
||||
const gripData = byType("GRIP_STRENGTH");
|
||||
if (gripData.length > 0) {
|
||||
const summary = summarize(toDataPoints(gripData));
|
||||
features.gripStrength = {
|
||||
latest: summary.latest,
|
||||
avg30: summary.avg30,
|
||||
slope30: summary.slope30?.slope ?? null,
|
||||
coverage: computeCoverage(gripData, now),
|
||||
};
|
||||
}
|
||||
|
||||
// Waist circumference (cm) + waist-to-height ratio. WHtR is computed from the
|
||||
// freshest circumference and the user's height (the same canonical derivation
|
||||
// the detail page uses); omitted when height is unknown.
|
||||
const waistData = byType("WAIST_CIRCUMFERENCE");
|
||||
if (waistData.length > 0) {
|
||||
const summary = summarize(toDataPoints(waistData));
|
||||
const whtrLatest =
|
||||
user?.heightCm && summary.latest
|
||||
? Math.round((summary.latest / user.heightCm) * 100) / 100
|
||||
: null;
|
||||
features.waist = {
|
||||
latest: summary.latest,
|
||||
avg30: summary.avg30,
|
||||
slope30: summary.slope30?.slope ?? null,
|
||||
whtrLatest,
|
||||
coverage: computeCoverage(waistData, now),
|
||||
};
|
||||
}
|
||||
|
||||
// Pain (0–10 NRS, lower-better) — surface a sustained or rising pain burden.
|
||||
const painData = byType("PAIN_NRS");
|
||||
if (painData.length > 0) {
|
||||
const summary = summarize(toDataPoints(painData));
|
||||
features.pain = {
|
||||
latest: summary.latest,
|
||||
avg7: summary.avg7,
|
||||
avg30: summary.avg30,
|
||||
slope30: summary.slope30?.slope ?? null,
|
||||
coverage: computeCoverage(painData, now),
|
||||
};
|
||||
}
|
||||
|
||||
// Sleep Duration
|
||||
//
|
||||
// SLEEP_DURATION is stored ONE ROW PER STAGE per night, so summarising the
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { deriveMetricStatusRegistry } from "@/lib/signals/adapters/metric-status";
|
||||
import { deriveMeasurementLoinc } from "@/lib/signals/adapters/fhir";
|
||||
import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation";
|
||||
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
|
||||
|
||||
import expectedMeasurementLoinc from "./fixtures/expected-measurement-loinc.json";
|
||||
import expectedBucketedTypes from "./fixtures/expected-bucketed-types.json";
|
||||
@@ -48,6 +49,22 @@ describe("signal registry — structural sanity", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("every biomarker signal's source key resolves to a real catalog slug", () => {
|
||||
// The declared registry↔labs bridge: a `kind: "biomarker"` signal's
|
||||
// `source.biomarkerKey` must name an actual `BIOMARKER_CATALOG` slug. The
|
||||
// length-only check above would let a typo (`"lpa"` vs `"lp-a"`) ride
|
||||
// through; this catches biomarker-slug drift the moment it lands.
|
||||
const catalogSlugs = new Set(BIOMARKER_CATALOG.map((b) => b.slug));
|
||||
for (const signal of allSignals()) {
|
||||
if (signal.kind === "biomarker") {
|
||||
expect(
|
||||
catalogSlugs.has(signal.source.biomarkerKey),
|
||||
`biomarker signal "${signal.key}" → unknown catalog slug "${signal.source.biomarkerKey}"`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("every Coach-snapshot signal is also MCP-readable", () => {
|
||||
// `mcp` and `coachSnapshot` are INDEPENDENT facets: a Coach-scoped signal
|
||||
// is always reachable over MCP (the Coach `get_metric_series` path), but a
|
||||
|
||||
Reference in new issue
Block a user