mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
@@ -24,14 +24,35 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.fixme("returning to /insights from a sub-page lands at the top", async ({
|
||||
test("returning to /insights from a sub-page lands at the top", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// v1.25.1 — the app-wide scroll-to-top resets the `#main-content`
|
||||
// scroll container on every route change (auth-shell), with a
|
||||
// `window.scrollTo(0,0)` fallback for the body-scrolled shells. The
|
||||
// authenticated insights surface scrolls `#main-content`, so the
|
||||
// regression lock reads that container, not `window`.
|
||||
const readScrollTop = () =>
|
||||
page.evaluate(() => {
|
||||
const el = document.getElementById("main-content");
|
||||
return el && el.scrollHeight > el.clientHeight
|
||||
? el.scrollTop
|
||||
: window.scrollY;
|
||||
});
|
||||
|
||||
// Force the mother page tall enough that scrolling has somewhere
|
||||
// to go even on a sparse seed; the rendered Insights overview
|
||||
// already runs ~1500 px tall on the demo seed.
|
||||
await page.evaluate(() => window.scrollTo({ top: 600, behavior: "auto" }));
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById("main-content");
|
||||
if (el && el.scrollHeight > el.clientHeight) {
|
||||
el.scrollTop = 600;
|
||||
} else {
|
||||
window.scrollTo({ top: 600, behavior: "auto" });
|
||||
}
|
||||
});
|
||||
|
||||
// Click the first available sub-page pill.
|
||||
const subPagePill = page
|
||||
@@ -45,11 +66,10 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
await page.locator("[data-slot='insights-tab-strip-pill']").first().click();
|
||||
await page.waitForURL(/\/insights\/?$/);
|
||||
|
||||
// The mother page's mount-effect deferred a `scrollTo(0)` to the
|
||||
// next animation frame. Wait a tick for it to land.
|
||||
await page.waitForTimeout(50);
|
||||
const y = await page.evaluate(() => window.scrollY);
|
||||
expect(y).toBeLessThan(50);
|
||||
// The shell's route-change effect resets the scroll on the next tick;
|
||||
// `useScrollResetOnRoute` defers to `requestAnimationFrame`. Wait for it
|
||||
// to settle, then assert the container is back at the top.
|
||||
await expect.poll(readScrollTop, { timeout: 2000 }).toBeLessThan(50);
|
||||
});
|
||||
|
||||
test("the sticky tab strip declares touch-action pan-y so vertical swipes pass through", async ({
|
||||
|
||||
+2
-3
@@ -7100,6 +7100,8 @@
|
||||
"detail": {
|
||||
"emptyTitle": "Noch keine Werte",
|
||||
"emptyDescription": "Füge einen Wert hinzu, um diesen Biomarker über die Zeit zu verfolgen.",
|
||||
"title": "Biomarker",
|
||||
"genericDescription": "Ein Laborwert, den du verfolgst. Erfasse Messwerte, um den Verlauf gegenüber dem Referenzbereich zu sehen.",
|
||||
"valuesTitle": "{marker} — alle Werte",
|
||||
"valuesDescription": "Jeder Messwert für diesen Biomarker. Bearbeite oder entferne einen Eintrag direkt.",
|
||||
"backToMarker": "Zurück zu {marker}",
|
||||
@@ -7970,9 +7972,6 @@
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
"intendedPurpose": "HealthLog ist ein persönliches Werkzeug zur Gesundheitsdokumentation und zum Wohlbefinden. Es hilft dir, deine eigenen Gesundheitsdaten zu erfassen, zu visualisieren und zu exportieren. Es diagnostiziert, behandelt, heilt oder verhindert keine Krankheit und ist kein Medizinprodukt.",
|
||||
"coach": "Der Coach arbeitet mit deinen eigenen erfassten Daten. Er ist kein Arzt und gibt keine medizinische Beratung — für Diagnose oder Behandlung wende dich an medizinisches Fachpersonal.",
|
||||
"emergency": "Falls das ein Notfall sein könnte, wende dich jetzt an deine örtliche Notrufnummer oder an eine ärztliche Stelle. HealthLog kann im Notfall nicht helfen.",
|
||||
"dataPosture": "HealthLog wird selbst gehostet: Deine Gesundheitsdaten bleiben auf deiner eigenen Instanz und sind im Ruhezustand verschlüsselt. KI-Funktionen nutzen einen von dir mitgebrachten Anbieter-Schlüssel oder ein lokales Modell — nichts verlässt deine Instanz über den von dir konfigurierten Anbieter hinaus, und es gibt keine Drittanbieter-Analyse deiner Gesundheitsdaten."
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -7100,6 +7100,8 @@
|
||||
"detail": {
|
||||
"emptyTitle": "No readings yet",
|
||||
"emptyDescription": "Add a value to start tracking this biomarker over time.",
|
||||
"title": "Biomarker",
|
||||
"genericDescription": "A lab biomarker you track. Add readings to follow how it trends against its reference range.",
|
||||
"valuesTitle": "{marker} — all readings",
|
||||
"valuesDescription": "Every reading for this biomarker. Edit or remove an entry directly.",
|
||||
"backToMarker": "Back to {marker}",
|
||||
@@ -7195,8 +7197,8 @@
|
||||
"ggt": "GGT",
|
||||
"sodium": "Sodium",
|
||||
"potassium": "Potassium",
|
||||
"hemoglobin": "Hemoglobin",
|
||||
"hematocrit": "Hematocrit",
|
||||
"hemoglobin": "Haemoglobin",
|
||||
"hematocrit": "Haematocrit",
|
||||
"wbc": "White blood cells",
|
||||
"platelets": "Platelets",
|
||||
"panel": {
|
||||
@@ -7970,9 +7972,6 @@
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
"intendedPurpose": "HealthLog is a personal health record-keeping and wellness tool. It helps you log, visualise, and export your own health data. It does not diagnose, treat, cure, or prevent any disease, and it is not a medical device.",
|
||||
"coach": "The Coach works from your own logged data. It isn't a doctor and doesn't give medical advice — for diagnosis or treatment, talk to a healthcare professional.",
|
||||
"emergency": "If this might be an emergency, contact your local emergency number or a clinician now. HealthLog can't help in an emergency.",
|
||||
"dataPosture": "HealthLog is self-hosted: your health data stays on your own instance, encrypted at rest. AI features use a provider key you bring or a local model, so nothing leaves your instance beyond the provider you configure, and there is no third-party analytics on your health data."
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -7100,6 +7100,8 @@
|
||||
"detail": {
|
||||
"emptyTitle": "Aún no hay mediciones",
|
||||
"emptyDescription": "Añade un valor para seguir este biomarcador con el tiempo.",
|
||||
"title": "Biomarcador",
|
||||
"genericDescription": "Un biomarcador de laboratorio que sigues. Añade lecturas para ver su evolución frente al rango de referencia.",
|
||||
"valuesTitle": "{marker} — todas las lecturas",
|
||||
"valuesDescription": "Cada lectura de este biomarcador. Edita o elimina una entrada directamente.",
|
||||
"backToMarker": "Volver a {marker}",
|
||||
@@ -7970,9 +7972,6 @@
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
"intendedPurpose": "HealthLog es una herramienta personal de registro de salud y bienestar. Te ayuda a registrar, visualizar y exportar tus propios datos de salud. No diagnostica, trata, cura ni previene ninguna enfermedad, y no es un producto sanitario.",
|
||||
"coach": "El Coach trabaja con tus propios datos registrados. No es un médico y no da consejo médico: para un diagnóstico o tratamiento, consulta a un profesional sanitario.",
|
||||
"emergency": "Si esto pudiera ser una emergencia, llama ahora a tu número de emergencias local o a un profesional sanitario. HealthLog no puede ayudar en una emergencia.",
|
||||
"dataPosture": "HealthLog es autoalojado: tus datos de salud permanecen en tu propia instancia, cifrados en reposo. Las funciones de IA usan una clave de proveedor que tú aportas o un modelo local, así que nada sale de tu instancia más allá del proveedor que configures, y no hay analítica de terceros sobre tus datos de salud."
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -7100,6 +7100,8 @@
|
||||
"detail": {
|
||||
"emptyTitle": "Aucune mesure pour l'instant",
|
||||
"emptyDescription": "Ajoutez une valeur pour suivre ce biomarqueur dans le temps.",
|
||||
"title": "Biomarqueur",
|
||||
"genericDescription": "Un biomarqueur biologique que vous suivez. Ajoutez des mesures pour observer son évolution par rapport à la plage de référence.",
|
||||
"valuesTitle": "{marker} — toutes les valeurs",
|
||||
"valuesDescription": "Chaque mesure de ce biomarqueur. Modifiez ou supprimez une entrée directement.",
|
||||
"backToMarker": "Retour à {marker}",
|
||||
@@ -7970,9 +7972,6 @@
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
"intendedPurpose": "HealthLog est un outil personnel de suivi de santé et de bien-être. Il vous aide à enregistrer, visualiser et exporter vos propres données de santé. Il ne diagnostique, ne traite, ne guérit ni ne prévient aucune maladie, et n'est pas un dispositif médical.",
|
||||
"coach": "Le Coach s'appuie sur vos propres données enregistrées. Ce n'est pas un médecin et il ne donne pas de conseil médical : pour un diagnostic ou un traitement, consultez un professionnel de santé.",
|
||||
"emergency": "Si cela pourrait être une urgence, contactez maintenant votre numéro d'urgence local ou un professionnel de santé. HealthLog ne peut pas aider en cas d'urgence.",
|
||||
"dataPosture": "HealthLog est auto-hébergé : vos données de santé restent sur votre propre instance, chiffrées au repos. Les fonctions d'IA utilisent une clé de fournisseur que vous apportez ou un modèle local, donc rien ne quitte votre instance au-delà du fournisseur que vous configurez, et il n'y a aucune analyse tierce de vos données de santé."
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -7100,6 +7100,8 @@
|
||||
"detail": {
|
||||
"emptyTitle": "Ancora nessuna misurazione",
|
||||
"emptyDescription": "Aggiungi un valore per seguire questo biomarcatore nel tempo.",
|
||||
"title": "Biomarcatore",
|
||||
"genericDescription": "Un biomarcatore di laboratorio che monitori. Aggiungi misurazioni per seguirne l'andamento rispetto all'intervallo di riferimento.",
|
||||
"valuesTitle": "{marker} — tutte le letture",
|
||||
"valuesDescription": "Ogni lettura di questo biomarcatore. Modifica o rimuovi una voce direttamente.",
|
||||
"backToMarker": "Torna a {marker}",
|
||||
@@ -7970,9 +7972,6 @@
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
"intendedPurpose": "HealthLog è uno strumento personale per la registrazione della salute e il benessere. Ti aiuta a registrare, visualizzare ed esportare i tuoi dati di salute. Non diagnostica, cura, guarisce né previene alcuna malattia e non è un dispositivo medico.",
|
||||
"coach": "Il Coach lavora sui tuoi dati registrati. Non è un medico e non fornisce consigli medici: per una diagnosi o una terapia rivolgiti a un professionista sanitario.",
|
||||
"emergency": "Se potrebbe trattarsi di un'emergenza, contatta subito il numero di emergenza locale o un medico. HealthLog non può aiutare in caso di emergenza.",
|
||||
"dataPosture": "HealthLog è self-hosted: i tuoi dati di salute restano sulla tua istanza, cifrati a riposo. Le funzioni di IA usano una chiave del provider che fornisci tu o un modello locale, quindi nulla lascia la tua istanza oltre il provider che configuri, e non c'è alcuna analisi di terze parti sui tuoi dati di salute."
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -7100,6 +7100,8 @@
|
||||
"detail": {
|
||||
"emptyTitle": "Brak pomiarów",
|
||||
"emptyDescription": "Dodaj wartość, aby śledzić ten biomarker w czasie.",
|
||||
"title": "Biomarker",
|
||||
"genericDescription": "Biomarker laboratoryjny, który śledzisz. Dodaj pomiary, aby obserwować jego trend względem zakresu referencyjnego.",
|
||||
"valuesTitle": "{marker} — wszystkie odczyty",
|
||||
"valuesDescription": "Każdy odczyt tego biomarkera. Edytuj lub usuń wpis bezpośrednio.",
|
||||
"backToMarker": "Powrót do {marker}",
|
||||
@@ -7970,9 +7972,6 @@
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
"intendedPurpose": "HealthLog to osobiste narzędzie do rejestrowania zdrowia i dbania o dobre samopoczucie. Pomaga zapisywać, wizualizować i eksportować własne dane zdrowotne. Nie diagnozuje, nie leczy ani nie zapobiega żadnej chorobie i nie jest wyrobem medycznym.",
|
||||
"coach": "Coach korzysta z Twoich własnych zapisanych danych. Nie jest lekarzem i nie udziela porad medycznych — po diagnozę lub leczenie zwróć się do pracownika ochrony zdrowia.",
|
||||
"emergency": "Jeśli to może być nagły przypadek, zadzwoń teraz pod lokalny numer alarmowy lub skontaktuj się z lekarzem. HealthLog nie pomoże w nagłym przypadku.",
|
||||
"dataPosture": "HealthLog jest hostowany samodzielnie: Twoje dane zdrowotne pozostają na Twojej instancji, zaszyfrowane w spoczynku. Funkcje AI korzystają z dostarczonego przez Ciebie klucza dostawcy lub modelu lokalnego, więc nic nie opuszcza Twojej instancji poza skonfigurowanym dostawcą i nie ma analityki firm trzecich na Twoich danych zdrowotnych."
|
||||
}
|
||||
}
|
||||
@@ -84,3 +84,8 @@ END $$;
|
||||
-- deleted_at leads the sort column to keep the scan covered.
|
||||
CREATE INDEX IF NOT EXISTS "inbound_documents_user_id_deleted_at_document_date_idx"
|
||||
ON "inbound_documents"("user_id", "deleted_at", "document_date");
|
||||
|
||||
-- DropIndex: the new `(user_id, deleted_at, document_date)` index above is a
|
||||
-- superset of the plain `(user_id, deleted_at)` index, so the 2-column index is
|
||||
-- redundant for read planning and only adds write/maintenance cost. Drop it.
|
||||
DROP INDEX IF EXISTS "inbound_documents_user_id_deleted_at_idx";
|
||||
@@ -5630,9 +5630,10 @@ model InboundDocument {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([userId, deletedAt])
|
||||
// The list always filters `deletedAt: null` and sorts by `documentDate`, so
|
||||
// deletedAt leads the sort column to keep the scan covered.
|
||||
// deletedAt leads the sort column to keep the scan covered. This composite is
|
||||
// a superset of the plain `(userId, deletedAt)` index, which is therefore
|
||||
// redundant for read planning and dropped.
|
||||
@@index([userId, deletedAt, documentDate])
|
||||
@@map("inbound_documents")
|
||||
}
|
||||
|
||||
@@ -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