mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge branch 'fix/374-share-render' into release/v1.25.0
This commit is contained in:
commit
32885d1218
13 files changed
+158
-36
No files matched your search
@@ -72,6 +72,15 @@ function isGate(out: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the props the page handed to the gate element. A client component
|
||||
* boundary can only carry serializable props — issue #374 was a `t` FUNCTION
|
||||
* crossing it, which throws at render.
|
||||
*/
|
||||
function gateProps(out: unknown): Record<string, unknown> {
|
||||
return (out as { props: Record<string, unknown> }).props;
|
||||
}
|
||||
|
||||
function resolvedContext() {
|
||||
return {
|
||||
shareLinkId: "link-1",
|
||||
@@ -126,6 +135,23 @@ describe("clinician share page", () => {
|
||||
expect(isGate(out)).toBe(true);
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
expect(loadData).not.toHaveBeenCalled();
|
||||
|
||||
// Issue #374 contract: every prop crossing the server→client boundary must
|
||||
// be serializable. NO prop value may be a function (a `t` function here
|
||||
// throws "Server Components render error"), and the copy must arrive as a
|
||||
// pre-resolved plain object.
|
||||
const props = gateProps(out);
|
||||
for (const value of Object.values(props)) {
|
||||
expect(typeof value).not.toBe("function");
|
||||
}
|
||||
expect(typeof props.token).toBe("string");
|
||||
const strings = props.strings as Record<string, unknown>;
|
||||
expect(strings).toBeTypeOf("object");
|
||||
expect(strings).not.toBeNull();
|
||||
expect(Object.values(strings).length).toBeGreaterThan(0);
|
||||
for (const value of Object.values(strings)) {
|
||||
expect(typeof value).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("renders a protected link WITH a valid unlock cookie", async () => {
|
||||
|
||||
@@ -82,7 +82,17 @@ export default async function ClinicianSharePage({
|
||||
if (!verifyUnlockValue(unlockValue, gate.tokenHash)) {
|
||||
const locale = await resolveLocale();
|
||||
const { t } = getServerTranslator(locale);
|
||||
return <ShareUnlockGate t={(key, vars) => t(key, vars)} token={token} />;
|
||||
// Resolve the gate copy on the server (it owns the locale) into a plain,
|
||||
// serializable object. A `t` function cannot cross the RSC boundary into
|
||||
// the client gate — passing one throws at render (issue #374).
|
||||
const strings = {
|
||||
title: t("clinicianView.unlock.title"),
|
||||
description: t("clinicianView.unlock.description"),
|
||||
label: t("clinicianView.unlock.label"),
|
||||
error: t("clinicianView.unlock.error"),
|
||||
submit: t("clinicianView.unlock.submit"),
|
||||
};
|
||||
return <ShareUnlockGate strings={strings} token={token} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Error boundary for the public clinician share route (`/c/<token>`).
|
||||
*
|
||||
* The share view carries no app chrome and no session, so a render failure must
|
||||
* not fall through to the full-app error screen. This renders a clean, locale-
|
||||
* aware, stack-free message and offers a single retry. No record data, no
|
||||
* digest, no stack — a probe learns nothing from a failed render.
|
||||
*/
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function ClinicianShareError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
|
||||
<div className="border-border bg-card space-y-4 rounded-lg border p-6 text-center">
|
||||
<h1 className="text-lg font-semibold">
|
||||
{t("clinicianView.loadError.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("clinicianView.loadError.description")}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => reset()}
|
||||
className="min-h-11 w-full sm:min-h-9"
|
||||
variant="outline"
|
||||
>
|
||||
{t("clinicianView.loadError.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
* a FENCED, muted wellness card carrying the load-bearing "descriptive, not a
|
||||
* clinical assessment / not a diagnosis" disclaimer.
|
||||
*/
|
||||
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report-pdf-core";
|
||||
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
|
||||
|
||||
|
||||
@@ -25,18 +25,26 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
vars?: Record<string, string | number>,
|
||||
) => string;
|
||||
/**
|
||||
* Plain, serializable copy for the gate. Resolved on the server (which owns the
|
||||
* locale) and passed across the RSC boundary as data — never a `t` function, as
|
||||
* functions cannot be serialized to a client component.
|
||||
*/
|
||||
export interface ShareUnlockGateStrings {
|
||||
title: string;
|
||||
description: string;
|
||||
label: string;
|
||||
error: string;
|
||||
submit: string;
|
||||
}
|
||||
|
||||
interface ShareUnlockGateProps {
|
||||
t: Translate;
|
||||
strings: ShareUnlockGateStrings;
|
||||
/** The raw `hls_` token from the path — used only to build the verify URL. */
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) {
|
||||
export function ShareUnlockGate({ strings, token }: ShareUnlockGateProps) {
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
@@ -86,18 +94,12 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) {
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="text-lg font-semibold">
|
||||
{t("clinicianView.unlock.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("clinicianView.unlock.description")}
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">{strings.title}</h1>
|
||||
<p className="text-muted-foreground text-sm">{strings.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-passphrase">
|
||||
{t("clinicianView.unlock.label")}
|
||||
</Label>
|
||||
<Label htmlFor="share-passphrase">{strings.label}</Label>
|
||||
<Input
|
||||
id="share-passphrase"
|
||||
value={passphrase}
|
||||
@@ -113,7 +115,7 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) {
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("clinicianView.unlock.error")}
|
||||
{strings.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -122,7 +124,7 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) {
|
||||
disabled={pending || !passphrase.trim()}
|
||||
className="min-h-11 w-full sm:min-h-9"
|
||||
>
|
||||
{t("clinicianView.unlock.submit")}
|
||||
{strings.submit}
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import { jsPDF } from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "./doctor-report/type-label-keys";
|
||||
import { makeFormatters } from "./format-locale";
|
||||
import type { Locale } from "./i18n/config";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "./glucose";
|
||||
@@ -150,23 +151,11 @@ export interface DoctorReportRenderOptions {
|
||||
aiSummary?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-vital label keys. Exported for coverage tests (issue #109 / phase P0)
|
||||
* so a future enum addition is caught by a unit test rather than reaching
|
||||
* production as a raw enum string in the PDF.
|
||||
*/
|
||||
export const DOCTOR_REPORT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
WEIGHT: "doctorReport.typeWeight",
|
||||
BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys",
|
||||
BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia",
|
||||
PULSE: "doctorReport.typePulse",
|
||||
BODY_FAT: "doctorReport.typeBodyFat",
|
||||
SLEEP_DURATION: "doctorReport.typeSleep",
|
||||
ACTIVITY_STEPS: "doctorReport.typeSteps",
|
||||
TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater",
|
||||
BONE_MASS: "doctorReport.typeBoneMass",
|
||||
OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation",
|
||||
};
|
||||
// Per-vital label keys live in a jsPDF-free module so leaf consumers (the
|
||||
// public clinician share view) can import the map without dragging the PDF
|
||||
// renderer into their graph. Re-exported here so the PDF core stays the
|
||||
// single import surface its existing callers already use.
|
||||
export { DOCTOR_REPORT_TYPE_LABEL_KEYS };
|
||||
|
||||
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
WEIGHT: "kg",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Per-vital i18n label keys for the doctor report.
|
||||
*
|
||||
* Extracted from `doctor-report-pdf-core` so consumers that only need the
|
||||
* label map (e.g. the public clinician share view, a pure server component)
|
||||
* do not pull the jsPDF renderer into their module graph. The PDF core
|
||||
* re-exports this map so there is a single source of truth.
|
||||
*
|
||||
* Exported for coverage tests (issue #109 / phase P0) so a future enum
|
||||
* addition is caught by a unit test rather than reaching production as a raw
|
||||
* enum string in the PDF.
|
||||
*/
|
||||
export const DOCTOR_REPORT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
WEIGHT: "doctorReport.typeWeight",
|
||||
BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys",
|
||||
BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia",
|
||||
PULSE: "doctorReport.typePulse",
|
||||
BODY_FAT: "doctorReport.typeBodyFat",
|
||||
SLEEP_DURATION: "doctorReport.typeSleep",
|
||||
ACTIVITY_STEPS: "doctorReport.typeSteps",
|
||||
TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater",
|
||||
BONE_MASS: "doctorReport.typeBoneMass",
|
||||
OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation",
|
||||
};
|
||||
Reference in new issue
Block a user