mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(share-links): public clinician view at /c/[token]
A standalone read-only RSC that resolves the path token through the share-token resolver and renders an owner-scoped clinical summary: provenance header, clinical vitals and labs, medications with adherence, and a fenced muted card for the descriptive wellness scores carrying the not-a-clinical-assessment, not-a-diagnosis disclaimer. KVNR is default off. No app chrome, no coach, no AI, no session, no markdown — every value renders as escaped text. force-dynamic and noindex; the page returns a flat 404 for any null resolve. The route joins the proxy public-path allowlist and the AuthShell standalone list so it reaches the page bare without a sign-in redirect. New clinicianView strings land in all six locales.
This commit is contained in:
1 parent
9830ae8027
commit
6aa3c75d27
13 files changed
+753
-1
No files matched your search
@@ -4523,5 +4523,33 @@
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Stimmung erfassen",
|
||||
"dailyBody": "Wie geht es dir heute?"
|
||||
},
|
||||
"clinicianView": {
|
||||
"title": "Geteilte Gesundheitsakte",
|
||||
"period": "Berichtszeitraum: {start} – {end}",
|
||||
"expires": "Dieser Link läuft am {date} ab.",
|
||||
"provenance": "Dies ist eine schreibgeschützte Zusammenfassung, die von der betreffenden Person geteilt wurde, exportiert aus ihren selbst erfassten HealthLog-Daten. Die Werte sind selbst erhoben und können geräteübertragene Messwerte enthalten; behandeln Sie sie als von der Patientin/dem Patienten berichtet, nicht als klinische Untersuchung.",
|
||||
"vitals": "Vitalwerte",
|
||||
"labs": "Laborwerte",
|
||||
"medications": "Medikamente & Einnahmetreue",
|
||||
"bmi": "Body-Mass-Index (BMI)",
|
||||
"statSummary": "zuletzt {latest} (Ø {avg}, Bereich {min}–{max})",
|
||||
"adherence": "{rate} Einnahmetreue",
|
||||
"noAdherence": "—",
|
||||
"glucose": {
|
||||
"FASTING": "Glukose (nüchtern)",
|
||||
"POSTPRANDIAL": "Glukose (postprandial)",
|
||||
"RANDOM": "Glukose (zufällig)",
|
||||
"BEDTIME": "Glukose (vor dem Schlafen)"
|
||||
},
|
||||
"wellness": {
|
||||
"title": "Wellness-Werte",
|
||||
"disclaimer": "Beschreibende Werte (0–100), berechnet aus erfassten Signalen. Dies ist keine klinische Beurteilung und keine Diagnose.",
|
||||
"recovery": "Erholungswert",
|
||||
"stress": "Stresswert",
|
||||
"strain": "Belastungswert",
|
||||
"score": "Wellness-Wert"
|
||||
},
|
||||
"footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker."
|
||||
}
|
||||
}
|
||||
@@ -4523,5 +4523,33 @@
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Log your mood",
|
||||
"dailyBody": "How are you feeling today?"
|
||||
},
|
||||
"clinicianView": {
|
||||
"title": "Shared health record",
|
||||
"period": "Reporting period: {start} – {end}",
|
||||
"expires": "This link expires on {date}.",
|
||||
"provenance": "This is a read-only summary shared by the person it belongs to, exported from their self-tracked HealthLog data. Values are self-recorded and may include device-synced readings; treat them as patient-reported, not as a clinical examination.",
|
||||
"vitals": "Vital signs",
|
||||
"labs": "Lab values",
|
||||
"medications": "Medications & adherence",
|
||||
"bmi": "Body mass index (BMI)",
|
||||
"statSummary": "latest {latest} (avg {avg}, range {min}–{max})",
|
||||
"adherence": "{rate} adherence",
|
||||
"noAdherence": "—",
|
||||
"glucose": {
|
||||
"FASTING": "Glucose (fasting)",
|
||||
"POSTPRANDIAL": "Glucose (postprandial)",
|
||||
"RANDOM": "Glucose (random)",
|
||||
"BEDTIME": "Glucose (bedtime)"
|
||||
},
|
||||
"wellness": {
|
||||
"title": "Wellness scores",
|
||||
"disclaimer": "Descriptive scores (0–100) computed from tracked signals. These are not a clinical assessment and not a diagnosis.",
|
||||
"recovery": "Recovery score",
|
||||
"stress": "Stress score",
|
||||
"strain": "Strain score",
|
||||
"score": "Wellness score"
|
||||
},
|
||||
"footer": "Shared from HealthLog — a self-hosted personal health tracker."
|
||||
}
|
||||
}
|
||||
@@ -4523,5 +4523,33 @@
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Registrar el ánimo",
|
||||
"dailyBody": "¿Cómo te sientes hoy?"
|
||||
},
|
||||
"clinicianView": {
|
||||
"title": "Registro de salud compartido",
|
||||
"period": "Período del informe: {start} – {end}",
|
||||
"expires": "Este enlace caduca el {date}.",
|
||||
"provenance": "Este es un resumen de solo lectura compartido por la persona a la que pertenece, exportado de sus datos de HealthLog autorregistrados. Los valores son autoinformados y pueden incluir lecturas sincronizadas desde dispositivos; trátelos como informados por el paciente, no como un examen clínico.",
|
||||
"vitals": "Signos vitales",
|
||||
"labs": "Valores de laboratorio",
|
||||
"medications": "Medicamentos y adherencia",
|
||||
"bmi": "Índice de masa corporal (IMC)",
|
||||
"statSummary": "último {latest} (prom. {avg}, rango {min}–{max})",
|
||||
"adherence": "{rate} de adherencia",
|
||||
"noAdherence": "—",
|
||||
"glucose": {
|
||||
"FASTING": "Glucosa (en ayunas)",
|
||||
"POSTPRANDIAL": "Glucosa (posprandial)",
|
||||
"RANDOM": "Glucosa (aleatoria)",
|
||||
"BEDTIME": "Glucosa (al acostarse)"
|
||||
},
|
||||
"wellness": {
|
||||
"title": "Puntuaciones de bienestar",
|
||||
"disclaimer": "Puntuaciones descriptivas (0–100) calculadas a partir de las señales registradas. No son una evaluación clínica ni un diagnóstico.",
|
||||
"recovery": "Puntuación de recuperación",
|
||||
"stress": "Puntuación de estrés",
|
||||
"strain": "Puntuación de esfuerzo",
|
||||
"score": "Puntuación de bienestar"
|
||||
},
|
||||
"footer": "Compartido desde HealthLog — un registro de salud autoalojado."
|
||||
}
|
||||
}
|
||||
@@ -4523,5 +4523,33 @@
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Enregistrer votre humeur",
|
||||
"dailyBody": "Comment vous sentez-vous aujourd’hui ?"
|
||||
},
|
||||
"clinicianView": {
|
||||
"title": "Dossier de santé partagé",
|
||||
"period": "Période couverte : {start} – {end}",
|
||||
"expires": "Ce lien expire le {date}.",
|
||||
"provenance": "Il s'agit d'un résumé en lecture seule partagé par la personne concernée, exporté de ses données HealthLog auto-enregistrées. Les valeurs sont auto-déclarées et peuvent inclure des mesures synchronisées depuis des appareils ; à considérer comme rapportées par le patient, non comme un examen clinique.",
|
||||
"vitals": "Signes vitaux",
|
||||
"labs": "Valeurs de laboratoire",
|
||||
"medications": "Médicaments et observance",
|
||||
"bmi": "Indice de masse corporelle (IMC)",
|
||||
"statSummary": "dernier {latest} (moy. {avg}, plage {min}–{max})",
|
||||
"adherence": "{rate} d'observance",
|
||||
"noAdherence": "—",
|
||||
"glucose": {
|
||||
"FASTING": "Glycémie (à jeun)",
|
||||
"POSTPRANDIAL": "Glycémie (postprandiale)",
|
||||
"RANDOM": "Glycémie (aléatoire)",
|
||||
"BEDTIME": "Glycémie (au coucher)"
|
||||
},
|
||||
"wellness": {
|
||||
"title": "Scores de bien-être",
|
||||
"disclaimer": "Scores descriptifs (0–100) calculés à partir des signaux suivis. Ce n'est ni une évaluation clinique ni un diagnostic.",
|
||||
"recovery": "Score de récupération",
|
||||
"stress": "Score de stress",
|
||||
"strain": "Score de charge",
|
||||
"score": "Score de bien-être"
|
||||
},
|
||||
"footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé."
|
||||
}
|
||||
}
|
||||
@@ -4523,5 +4523,33 @@
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Registra il tuo umore",
|
||||
"dailyBody": "Come ti senti oggi?"
|
||||
},
|
||||
"clinicianView": {
|
||||
"title": "Cartella sanitaria condivisa",
|
||||
"period": "Periodo del report: {start} – {end}",
|
||||
"expires": "Questo link scade il {date}.",
|
||||
"provenance": "Questo è un riepilogo di sola lettura condiviso dalla persona a cui appartiene, esportato dai suoi dati HealthLog autoregistrati. I valori sono autodichiarati e possono includere letture sincronizzate da dispositivi; vanno considerati come riferiti dal paziente, non come un esame clinico.",
|
||||
"vitals": "Parametri vitali",
|
||||
"labs": "Valori di laboratorio",
|
||||
"medications": "Farmaci e aderenza",
|
||||
"bmi": "Indice di massa corporea (IMC)",
|
||||
"statSummary": "ultimo {latest} (media {avg}, intervallo {min}–{max})",
|
||||
"adherence": "{rate} di aderenza",
|
||||
"noAdherence": "—",
|
||||
"glucose": {
|
||||
"FASTING": "Glicemia (a digiuno)",
|
||||
"POSTPRANDIAL": "Glicemia (postprandiale)",
|
||||
"RANDOM": "Glicemia (casuale)",
|
||||
"BEDTIME": "Glicemia (prima di dormire)"
|
||||
},
|
||||
"wellness": {
|
||||
"title": "Punteggi di benessere",
|
||||
"disclaimer": "Punteggi descrittivi (0–100) calcolati dai segnali monitorati. Non sono una valutazione clinica né una diagnosi.",
|
||||
"recovery": "Punteggio di recupero",
|
||||
"stress": "Punteggio di stress",
|
||||
"strain": "Punteggio di sforzo",
|
||||
"score": "Punteggio di benessere"
|
||||
},
|
||||
"footer": "Condiviso da HealthLog — un tracker della salute self-hosted."
|
||||
}
|
||||
}
|
||||
@@ -4523,5 +4523,33 @@
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Zapisz nastrój",
|
||||
"dailyBody": "Jak się dzisiaj czujesz?"
|
||||
},
|
||||
"clinicianView": {
|
||||
"title": "Udostępniona dokumentacja zdrowotna",
|
||||
"period": "Okres raportu: {start} – {end}",
|
||||
"expires": "Ten link wygasa dnia {date}.",
|
||||
"provenance": "To jest tylko do odczytu podsumowanie udostępnione przez osobę, do której należy, wyeksportowane z jej samodzielnie rejestrowanych danych HealthLog. Wartości są zgłaszane samodzielnie i mogą obejmować odczyty zsynchronizowane z urządzeń; należy je traktować jako zgłoszone przez pacjenta, a nie jako badanie kliniczne.",
|
||||
"vitals": "Parametry życiowe",
|
||||
"labs": "Wyniki laboratoryjne",
|
||||
"medications": "Leki i przestrzeganie zaleceń",
|
||||
"bmi": "Wskaźnik masy ciała (BMI)",
|
||||
"statSummary": "ostatnio {latest} (śr. {avg}, zakres {min}–{max})",
|
||||
"adherence": "{rate} przestrzegania",
|
||||
"noAdherence": "—",
|
||||
"glucose": {
|
||||
"FASTING": "Glukoza (na czczo)",
|
||||
"POSTPRANDIAL": "Glukoza (poposiłkowa)",
|
||||
"RANDOM": "Glukoza (losowa)",
|
||||
"BEDTIME": "Glukoza (przed snem)"
|
||||
},
|
||||
"wellness": {
|
||||
"title": "Wyniki dobrostanu",
|
||||
"disclaimer": "Opisowe wyniki (0–100) obliczone na podstawie śledzonych sygnałów. Nie są one oceną kliniczną ani diagnozą.",
|
||||
"recovery": "Wynik regeneracji",
|
||||
"stress": "Wynik stresu",
|
||||
"strain": "Wynik obciążenia",
|
||||
"score": "Wynik dobrostanu"
|
||||
},
|
||||
"footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* v1.11.0 (Epic C, C5) — public clinician-view page gating.
|
||||
*
|
||||
* Asserts the page answers a flat `notFound()` (404) whenever the resolver
|
||||
* yields null — the single blunt response for unknown / revoked / expired /
|
||||
* malformed tokens — and renders the scoped view on a successful resolve.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
class NotFoundError extends Error {
|
||||
digest = "NEXT_HTTP_ERROR_FALLBACK;404";
|
||||
}
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: vi.fn(() => {
|
||||
throw new NotFoundError("NEXT_NOT_FOUND");
|
||||
}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined })),
|
||||
}));
|
||||
vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
|
||||
resolveShareToken: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/clinician-share/share-view-data", () => ({
|
||||
loadShareViewData: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/components/clinician/clinician-view", () => ({
|
||||
ClinicianView: () => null,
|
||||
}));
|
||||
|
||||
import ClinicianSharePage from "../page";
|
||||
import { resolveShareToken } from "@/lib/clinician-share/resolve-share-token";
|
||||
import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
const resolve = resolveShareToken as ReturnType<typeof vi.fn>;
|
||||
const loadData = loadShareViewData as ReturnType<typeof vi.fn>;
|
||||
|
||||
function pageProps(token: string) {
|
||||
return { params: Promise.resolve({ token }) };
|
||||
}
|
||||
|
||||
describe("clinician share page", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("404s when the token resolves to null (revoked / expired / unknown)", async () => {
|
||||
resolve.mockResolvedValue(null);
|
||||
await expect(
|
||||
ClinicianSharePage(pageProps(`hls_${"a".repeat(48)}`)),
|
||||
).rejects.toBeInstanceOf(NotFoundError);
|
||||
expect(notFound).toHaveBeenCalledTimes(1);
|
||||
// No data load is attempted on a failed resolve.
|
||||
expect(loadData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads the scoped view on a successful resolve", async () => {
|
||||
resolve.mockResolvedValue({
|
||||
shareLinkId: "link-1",
|
||||
ownerUserId: "owner-1",
|
||||
label: "Clinic",
|
||||
rangeStart: new Date(),
|
||||
rangeEnd: null,
|
||||
sectionsJson: {},
|
||||
resourceTypes: [],
|
||||
allowFhirApi: false,
|
||||
expiresAt: new Date(Date.now() + 86_400_000),
|
||||
});
|
||||
loadData.mockResolvedValue({ report: {}, sections: {} });
|
||||
await ClinicianSharePage(pageProps(`hls_${"b".repeat(48)}`));
|
||||
expect(notFound).not.toHaveBeenCalled();
|
||||
expect(loadData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* v1.11.0 — public clinician view (Epic C, C5).
|
||||
*
|
||||
* A standalone, read-only RSC at `/c/<token>`. It carries NO app chrome, NO
|
||||
* coach / AI / insight generation, and NO session — it is authenticated solely
|
||||
* by the unguessable `hls_` token in the path, resolved through the C3
|
||||
* {@link resolveShareToken} security core (the only trust boundary here).
|
||||
*
|
||||
* An unknown / revoked / expired / malformed token resolves to `null` and the
|
||||
* page answers a flat `notFound()` (404) — the same blunt response for every
|
||||
* failure class so a probe learns nothing.
|
||||
*
|
||||
* The descriptive wellness scores are fenced inside a muted card carrying the
|
||||
* load-bearing "not a clinical assessment / not a diagnosis" disclaimer. KVNR
|
||||
* is default OFF (never decrypted or shown here). No markdown anywhere — every
|
||||
* string renders as escaped React text children (XSS posture).
|
||||
*/
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { resolveShareToken } from "@/lib/clinician-share/resolve-share-token";
|
||||
import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import {
|
||||
defaultLocale,
|
||||
locales,
|
||||
type Locale,
|
||||
} from "@/lib/i18n/config";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { ClinicianView } from "@/components/clinician/clinician-view";
|
||||
|
||||
// Never cache a scoped health view — `no-store` end to end.
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
// A shared clinical record must never be indexed.
|
||||
export const metadata: Metadata = {
|
||||
title: "Shared health record",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
async function resolveLocale(): Promise<Locale> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const cookieLocale = cookieStore.get("healthlog-locale")?.value;
|
||||
if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
|
||||
return cookieLocale as Locale;
|
||||
}
|
||||
const headerList = await headers();
|
||||
return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
|
||||
} catch {
|
||||
return defaultLocale;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ClinicianSharePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
|
||||
// The ONE trust boundary. No session is read; this proves only that the raw
|
||||
// path token hashes to a live, in-window share link and yields the owner
|
||||
// scope. Any failure → null → flat 404.
|
||||
const context = await resolveShareToken(token);
|
||||
if (!context) notFound();
|
||||
|
||||
const [{ report, sections }, locale] = await Promise.all([
|
||||
loadShareViewData(context),
|
||||
resolveLocale(),
|
||||
]);
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
return (
|
||||
<ClinicianView
|
||||
t={(key, vars) => t(key, vars)}
|
||||
label={context.label}
|
||||
expiresAt={context.expiresAt.toISOString()}
|
||||
report={report}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* v1.11.0 (Epic C, C5) — clinician-view presentation.
|
||||
*
|
||||
* Asserts the load-bearing rendering properties: the fenced wellness card
|
||||
* carries the descriptive "not a clinical assessment / not a diagnosis"
|
||||
* disclaimer, the provenance header renders, and the view holds no app chrome.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ClinicianView } from "../clinician-view";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
|
||||
function makeReport(
|
||||
overrides: Partial<DoctorReportData> = {},
|
||||
): DoctorReportData {
|
||||
return {
|
||||
period: {
|
||||
days: 30,
|
||||
since: "2026-01-01",
|
||||
start: "2026-01-01T00:00:00.000Z",
|
||||
end: "2026-01-31T00:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "tester",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
},
|
||||
practiceName: null,
|
||||
measurements: {},
|
||||
stats: {
|
||||
WEIGHT: { avg: 80, min: 78, max: 82, count: 12, latest: 79 },
|
||||
},
|
||||
glucoseStats: {},
|
||||
glucoseRanges: {},
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: 24.5,
|
||||
compliance: {},
|
||||
medications: [],
|
||||
wellnessScores: [
|
||||
{
|
||||
type: "RECOVERY_SCORE",
|
||||
latest: 72,
|
||||
avg: 68,
|
||||
min: 50,
|
||||
max: 90,
|
||||
count: 20,
|
||||
latestAt: "2026-01-30T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
} as DoctorReportData;
|
||||
}
|
||||
|
||||
function render(report: DoctorReportData) {
|
||||
const { t } = getServerTranslator("en");
|
||||
return renderToStaticMarkup(
|
||||
<ClinicianView
|
||||
t={(k, v) => t(k, v)}
|
||||
label="Cardiology clinic"
|
||||
expiresAt="2026-03-01T00:00:00.000Z"
|
||||
report={report}
|
||||
sections={{ ...DEFAULT_DOCTOR_REPORT_PREFS }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ClinicianView>", () => {
|
||||
it("renders the fenced wellness card with the descriptive disclaimer", () => {
|
||||
const html = render(makeReport());
|
||||
expect(html).toContain("Wellness scores");
|
||||
expect(html).toContain("not a clinical assessment");
|
||||
expect(html).toContain("not a diagnosis");
|
||||
expect(html).toContain("Recovery score");
|
||||
});
|
||||
|
||||
it("renders the provenance header treating values as patient-reported", () => {
|
||||
const html = render(makeReport());
|
||||
expect(html).toContain("Shared health record");
|
||||
expect(html).toContain("patient-reported");
|
||||
expect(html).toContain("Cardiology clinic");
|
||||
});
|
||||
|
||||
it("renders clinical vitals from the scoped report", () => {
|
||||
const html = render(makeReport());
|
||||
expect(html).toContain("Vital signs");
|
||||
// The measurement-type enum is humanised, not raw.
|
||||
expect(html).toContain("Weight");
|
||||
expect(html).not.toContain("WEIGHT");
|
||||
});
|
||||
|
||||
it("omits the wellness card when there are no scores", () => {
|
||||
const html = render(makeReport({ wellnessScores: [] }));
|
||||
expect(html).not.toContain("Wellness scores");
|
||||
});
|
||||
|
||||
it("carries no app chrome (no nav / coach landmarks)", () => {
|
||||
const html = render(makeReport());
|
||||
expect(html).not.toContain('data-slot="sidebar"');
|
||||
expect(html).not.toContain("coach");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* v1.11.0 — clinician-view presentation (Epic C, C5).
|
||||
*
|
||||
* A pure server component: it receives an already-resolved, owner-scoped
|
||||
* {@link DoctorReportData} plus a server-side translator and renders a
|
||||
* read-only clinical summary. NO client hooks, NO session, NO AI/coach, NO
|
||||
* markdown — every value renders as escaped React text.
|
||||
*
|
||||
* Layout: provenance header → clinical vitals/labs → medications + adherence →
|
||||
* a FENCED, muted wellness card carrying the load-bearing "descriptive, not a
|
||||
* clinical assessment / not a diagnosis" disclaimer.
|
||||
*/
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
vars?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
interface ClinicianViewProps {
|
||||
t: Translate;
|
||||
/** Owner-set label for the share (e.g. a clinic note). */
|
||||
label: string;
|
||||
/** ISO expiry instant — surfaced so the clinician knows the link lifetime. */
|
||||
expiresAt: string;
|
||||
report: DoctorReportData;
|
||||
sections: DoctorReportPrefs;
|
||||
}
|
||||
|
||||
/** Human-readable display per persisted wellness-score type (i18n key suffix). */
|
||||
const WELLNESS_KEY: Record<string, string> = {
|
||||
RECOVERY_SCORE: "recovery",
|
||||
STRESS_SCORE: "stress",
|
||||
STRAIN_SCORE: "strain",
|
||||
};
|
||||
|
||||
/**
|
||||
* Humanise a measurement-type enum (`BLOOD_PRESSURE_SYS` → "Blood pressure
|
||||
* sys") for a clinical label. The clinician view is locale-neutral on the raw
|
||||
* metric name by design — the enum is the stable, language-independent anchor a
|
||||
* clinician reads alongside the numbers; no per-type i18n bundle is maintained.
|
||||
*/
|
||||
function humaniseType(type: string): string {
|
||||
const lower = type.replace(/_/g, " ").toLowerCase();
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
}
|
||||
|
||||
/** Render a single labelled stat row. */
|
||||
function StatRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 border-b border-border/40 py-2 last:border-0">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<h2 className="mb-3 text-base font-semibold">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClinicianView({
|
||||
t,
|
||||
label,
|
||||
expiresAt,
|
||||
report,
|
||||
sections,
|
||||
}: ClinicianViewProps) {
|
||||
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString();
|
||||
const fmtNum = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
// Measurement series with at least one stat, mapped to their stats row.
|
||||
const measurementEntries = Object.entries(report.stats).filter(
|
||||
([, s]) => s.count > 0,
|
||||
);
|
||||
const glucoseEntries = Object.entries(report.glucoseStats).filter(
|
||||
([, s]) => s.count > 0,
|
||||
);
|
||||
const complianceEntries = sections.compliance
|
||||
? Object.entries(report.compliance).filter(([, c]) => c.total > 0)
|
||||
: [];
|
||||
const wellness =
|
||||
report.wellnessScores?.filter((s) => s.count > 0) ?? [];
|
||||
|
||||
return (
|
||||
<main
|
||||
id="main-content"
|
||||
className="mx-auto min-h-dvh w-full max-w-3xl px-4 py-8"
|
||||
>
|
||||
{/* ── Provenance header ───────────────────────────────────────── */}
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("clinicianView.title")}
|
||||
</h1>
|
||||
{label ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{label}</p>
|
||||
) : null}
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{t("clinicianView.period", {
|
||||
start: fmtDate(report.period.start),
|
||||
end: fmtDate(report.period.end),
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("clinicianView.expires", { date: fmtDate(expiresAt) })}
|
||||
</p>
|
||||
<p className="mt-3 rounded-md border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
|
||||
{t("clinicianView.provenance")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* ── Clinical vitals ─────────────────────────────────────── */}
|
||||
{measurementEntries.length > 0 ? (
|
||||
<Section title={t("clinicianView.vitals")}>
|
||||
{measurementEntries.map(([type, s]) => (
|
||||
<StatRow
|
||||
key={type}
|
||||
label={humaniseType(type)}
|
||||
value={t("clinicianView.statSummary", {
|
||||
latest: fmtNum(s.latest),
|
||||
avg: fmtNum(s.avg),
|
||||
min: fmtNum(s.min),
|
||||
max: fmtNum(s.max),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
{report.bmi !== null && report.bmi !== undefined && sections.bmi ? (
|
||||
<StatRow
|
||||
label={t("clinicianView.bmi")}
|
||||
value={String(fmtNum(report.bmi))}
|
||||
/>
|
||||
) : null}
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* ── Labs (glucose) ──────────────────────────────────────── */}
|
||||
{glucoseEntries.length > 0 ? (
|
||||
<Section title={t("clinicianView.labs")}>
|
||||
{glucoseEntries.map(([ctx, s]) => (
|
||||
<StatRow
|
||||
key={ctx}
|
||||
label={t(`clinicianView.glucose.${ctx}`)}
|
||||
value={t("clinicianView.statSummary", {
|
||||
latest: fmtNum(s.latest),
|
||||
avg: fmtNum(s.avg),
|
||||
min: fmtNum(s.min),
|
||||
max: fmtNum(s.max),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* ── Medications + adherence ─────────────────────────────── */}
|
||||
{report.medications.length > 0 ? (
|
||||
<Section title={t("clinicianView.medications")}>
|
||||
{report.medications.map((med) => {
|
||||
const comp = report.compliance[med.name];
|
||||
const rate =
|
||||
sections.compliance && comp && comp.total > 0
|
||||
? `${Math.round((comp.taken / comp.total) * 100)}%`
|
||||
: null;
|
||||
return (
|
||||
<StatRow
|
||||
key={med.name}
|
||||
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
|
||||
value={
|
||||
rate
|
||||
? t("clinicianView.adherence", { rate })
|
||||
: t("clinicianView.noAdherence")
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{complianceEntries
|
||||
.filter(
|
||||
([name]) => !report.medications.some((m) => m.name === name),
|
||||
)
|
||||
.map(([name, c]) => (
|
||||
<StatRow
|
||||
key={name}
|
||||
label={name}
|
||||
value={t("clinicianView.adherence", {
|
||||
rate: `${Math.round((c.taken / c.total) * 100)}%`,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* ── Fenced wellness card (descriptive, NOT clinical) ────── */}
|
||||
{wellness.length > 0 ? (
|
||||
<section className="rounded-lg border border-dashed border-amber-500/50 bg-amber-500/5 p-5">
|
||||
<h2 className="mb-1 text-base font-semibold text-muted-foreground">
|
||||
{t("clinicianView.wellness.title")}
|
||||
</h2>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
{t("clinicianView.wellness.disclaimer")}
|
||||
</p>
|
||||
<div>
|
||||
{wellness.map((s) => (
|
||||
<StatRow
|
||||
key={s.type}
|
||||
label={t(
|
||||
`clinicianView.wellness.${WELLNESS_KEY[s.type] ?? "score"}`,
|
||||
)}
|
||||
value={t("clinicianView.statSummary", {
|
||||
latest: fmtNum(s.latest),
|
||||
avg: fmtNum(s.avg),
|
||||
min: fmtNum(s.min),
|
||||
max: fmtNum(s.max),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<footer className="mt-8 border-t border-border pt-4 text-center text-xs text-muted-foreground">
|
||||
{t("clinicianView.footer")}
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,11 @@ const PUBLIC_PATHS = [
|
||||
"/auth/register",
|
||||
"/privacy",
|
||||
"/about",
|
||||
// v1.11.0 — the public clinician view renders its own standalone chrome
|
||||
// (no nav / coach / auth gate). Listed here so the shell hands it through
|
||||
// bare; the route is also in `proxy.ts` PUBLIC_PATHS so it never round-trips
|
||||
// the sign-in redirect.
|
||||
"/c/",
|
||||
];
|
||||
|
||||
export function AuthShell({ children }: { children: React.ReactNode }) {
|
||||
@@ -41,8 +46,12 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
|
||||
// auth gate above.
|
||||
// v1.4.27 MB6 — `/about` follows the same shape (own header, own
|
||||
// footer, full-width body), so it joins the standalone list.
|
||||
// v1.11.0 — the clinician view (`/c/*`) renders edge-to-edge with its own
|
||||
// standalone chrome and no app shell, like the legal pages.
|
||||
const isStandalonePublicPage =
|
||||
pathname.startsWith("/privacy") || pathname.startsWith("/about");
|
||||
pathname.startsWith("/privacy") ||
|
||||
pathname.startsWith("/about") ||
|
||||
pathname.startsWith("/c/");
|
||||
const isAdminPage = pathname.startsWith("/admin");
|
||||
const isOnboardingPage = pathname === "/onboarding";
|
||||
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* v1.11.0 — scoped data load for the public clinician view (Epic C, C5).
|
||||
*
|
||||
* Given a {@link ShareContext} (already proven by {@link resolveShareToken}),
|
||||
* aggregate exactly the data the owner froze into the link: the doctor-report
|
||||
* payload over the frozen `[rangeStart, rangeEnd]` window with the frozen
|
||||
* section toggles. The owner `userId` comes ONLY from the share context — never
|
||||
* from a session, never from the wire.
|
||||
*
|
||||
* KVNR is DEFAULT OFF: the clinician view never decrypts or surfaces the
|
||||
* insurance number. The descriptive wellness scores are kept (the view fences
|
||||
* them under an explicit "not a clinical assessment" card), but they are read
|
||||
* straight from the aggregator — no AI call, no coach, no insight generation.
|
||||
*/
|
||||
import {
|
||||
collectDoctorReportData,
|
||||
type DoctorReportData,
|
||||
type DoctorReportRange,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { parseDoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
|
||||
import type { ShareContext } from "@/lib/clinician-share/resolve-share-token";
|
||||
|
||||
export interface ShareViewData {
|
||||
/** The aggregated, owner-scoped report payload over the frozen window. */
|
||||
report: DoctorReportData;
|
||||
/** The resolved section toggles (mood opt-in, defaults otherwise). */
|
||||
sections: ReturnType<typeof parseDoctorReportPrefs>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the frozen reporting window from the share context. `rangeEnd` null
|
||||
* means "rolling up to now"; the start is always the absolute instant the
|
||||
* owner froze, so a rolling share can never reach data older than chosen.
|
||||
*/
|
||||
function frozenRange(context: ShareContext): DoctorReportRange {
|
||||
const start = context.rangeStart;
|
||||
const end = context.rangeEnd ?? new Date();
|
||||
const spanDays = Math.max(
|
||||
1,
|
||||
Math.ceil((end.getTime() - start.getTime()) / 86_400_000),
|
||||
);
|
||||
return { start, end, days: spanDays };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the scoped read-only view for a resolved share token. Pure data
|
||||
* assembly — no auth (the token was already proven), no rate-limit (the route
|
||||
* owns that), no session, no AI.
|
||||
*/
|
||||
export async function loadShareViewData(
|
||||
context: ShareContext,
|
||||
): Promise<ShareViewData> {
|
||||
const sections = parseDoctorReportPrefs(context.sectionsJson);
|
||||
const range = frozenRange(context);
|
||||
|
||||
const report = await collectDoctorReportData(context.ownerUserId, range, {
|
||||
sections,
|
||||
});
|
||||
|
||||
return { report, sections };
|
||||
}
|
||||
@@ -32,6 +32,11 @@ const PUBLIC_PATHS = [
|
||||
// alongside the project credits. The CC licence requires the
|
||||
// attribution to be reachable without a sign-in.
|
||||
"/about",
|
||||
// v1.11.0 — `/c/<token>` is the public clinician view (Epic C). It is
|
||||
// authenticated solely by the unguessable `hls_` token in the path, NOT
|
||||
// by a session cookie, so it must reach the page without an auth gate.
|
||||
// The page renders a flat 404 for any unknown / revoked / expired token.
|
||||
"/c/",
|
||||
// `/onboarding` itself + its subroutes are matched exactly via
|
||||
// `isPublicPath()` so we don't admit `/onboarding-export` etc.
|
||||
"/robots.txt",
|
||||
|
||||
Reference in new issue
Block a user