mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(doctor-report): per-section toggle UI + persistence
This commit is contained in:
1 parent
b1a3d59918
commit
5cb4a1dde1
7 files changed
+737
-13
No files matched your search
+19
-2
@@ -137,6 +137,18 @@
|
||||
"errorEndBeforeStart": "Das Ende-Datum darf nicht vor dem Start liegen.",
|
||||
"errorRangeTooLong": "Der Auswertungszeitraum darf maximal 2 Jahre umfassen.",
|
||||
"errorInvalidDate": "Bitte wähle ein gültiges Start- und Ende-Datum."
|
||||
},
|
||||
"sections": {
|
||||
"title": "Inhalte auswählen",
|
||||
"bp": "Blutdruck",
|
||||
"weight": "Gewicht",
|
||||
"pulse": "Ruhepuls",
|
||||
"bmi": "Body-Mass-Index (BMI)",
|
||||
"mood": "Stimmung",
|
||||
"moodSensitive": "Sensibel — standardmäßig aus.",
|
||||
"compliance": "Medikamenten-Compliance",
|
||||
"sleep": "Schlafdauer",
|
||||
"empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
@@ -674,7 +686,6 @@
|
||||
},
|
||||
"comparison": {
|
||||
"toggleLabel": "Vergleichen mit",
|
||||
"toggleHint": "Blendet eine gedimmte Vorperioden-Linie in jedem Chart ein und zeigt ein Delta-Callout auf jeder Kachel.",
|
||||
"baseline": {
|
||||
"none": "Aus",
|
||||
"lastMonth": "Vormonat",
|
||||
@@ -920,12 +931,18 @@
|
||||
"settingsExcludeHint": "Der Coach sieht keine Daten der ausgewählten Metriken.",
|
||||
"settingsEvidenceLabel": "Belege standardmäßig zeigen",
|
||||
"settingsEvidenceHint": "Die Belege-Box unter jeder Antwort automatisch öffnen.",
|
||||
"settingsDefaultWindowLabel": "Standard-Analysezeitraum",
|
||||
"settingsDefaultWindowHint": "Der Coach liest pro Chat diesen Zeitraum, sofern du im Drawer-Kopf nichts anderes auswählst.",
|
||||
"settingsSave": "Speichern",
|
||||
"settingsSaved": "Gespeichert.",
|
||||
"settingsCancel": "Abbrechen",
|
||||
"feedbackHelpful": "Hilfreich",
|
||||
"feedbackUnhelpful": "Nicht ganz",
|
||||
"feedbackThanks": "Danke für das Signal."
|
||||
"feedbackThanks": "Danke für das Signal.",
|
||||
"dailyLimitTitle": "Heute-Limit erreicht",
|
||||
"dailyLimitBody": "Heute-Limit erreicht; Reset um 00:00 UTC.",
|
||||
"providerRateLimitTitle": "Provider rate-limited",
|
||||
"providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min."
|
||||
},
|
||||
"viewAll": "Alle ansehen",
|
||||
"previewEmpty": "Noch keine Empfehlungen.",
|
||||
|
||||
+19
-2
@@ -137,6 +137,18 @@
|
||||
"errorEndBeforeStart": "End date must be on or after the start date.",
|
||||
"errorRangeTooLong": "The reporting period cannot exceed 2 years.",
|
||||
"errorInvalidDate": "Please select a valid start and end date."
|
||||
},
|
||||
"sections": {
|
||||
"title": "Choose sections",
|
||||
"bp": "Blood pressure",
|
||||
"weight": "Weight",
|
||||
"pulse": "Resting pulse",
|
||||
"bmi": "Body Mass Index (BMI)",
|
||||
"mood": "Mood",
|
||||
"moodSensitive": "Sensitive — off by default.",
|
||||
"compliance": "Medication compliance",
|
||||
"sleep": "Sleep duration",
|
||||
"empty": "No data was recorded in this date range yet. The report will still include the cover page."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
@@ -674,7 +686,6 @@
|
||||
},
|
||||
"comparison": {
|
||||
"toggleLabel": "Compare to",
|
||||
"toggleHint": "Adds a dimmed prior-period overlay to every chart and a delta callout to every tile.",
|
||||
"baseline": {
|
||||
"none": "None",
|
||||
"lastMonth": "Last month",
|
||||
@@ -920,12 +931,18 @@
|
||||
"settingsExcludeHint": "The Coach will not see data for selected metrics.",
|
||||
"settingsEvidenceLabel": "Show evidence by default",
|
||||
"settingsEvidenceHint": "Open the disclosure under every reply automatically.",
|
||||
"settingsDefaultWindowLabel": "Default analysis window",
|
||||
"settingsDefaultWindowHint": "The Coach reads this much history per chat unless you override it from the drawer header.",
|
||||
"settingsSave": "Save",
|
||||
"settingsSaved": "Saved.",
|
||||
"settingsCancel": "Cancel",
|
||||
"feedbackHelpful": "Helpful",
|
||||
"feedbackUnhelpful": "Not quite",
|
||||
"feedbackThanks": "Thanks for the signal."
|
||||
"feedbackThanks": "Thanks for the signal.",
|
||||
"dailyLimitTitle": "Daily limit reached",
|
||||
"dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.",
|
||||
"providerRateLimitTitle": "Provider rate-limited",
|
||||
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min."
|
||||
},
|
||||
"viewAll": "View all",
|
||||
"previewEmpty": "No recommendations yet.",
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPut(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/doctor-report-prefs", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/doctor-report-prefs", () => {
|
||||
it("returns defaults when the column is null", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/doctor-report-prefs"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: typeof DEFAULT_DOCTOR_REPORT_PREFS };
|
||||
expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
|
||||
expect(env.data.mood).toBe(false); // privacy default per Marc
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/doctor-report-prefs", () => {
|
||||
// ── Case 1 — happy path ─────────────────────────────────────────────
|
||||
it("persists the full shape and returns the canonical form", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const body = {
|
||||
bp: true,
|
||||
weight: true,
|
||||
pulse: false,
|
||||
bmi: true,
|
||||
mood: true,
|
||||
compliance: true,
|
||||
sleep: false,
|
||||
};
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: typeof body };
|
||||
expect(env.data).toEqual(body);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { doctorReportPrefsJson: body },
|
||||
});
|
||||
});
|
||||
|
||||
// ── Case 2 — partial update layers over current persisted row ───────
|
||||
it("merges a partial update over the persisted row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const current = {
|
||||
bp: false,
|
||||
weight: false,
|
||||
pulse: false,
|
||||
bmi: false,
|
||||
mood: true,
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: current,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
// User only flips mood off — every other key keeps its persisted value.
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ mood: false }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: typeof current };
|
||||
expect(env.data).toEqual({ ...current, mood: false });
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { doctorReportPrefsJson: { ...current, mood: false } },
|
||||
});
|
||||
});
|
||||
|
||||
// ── Case 3 — invalid shape ─────────────────────────────────────────
|
||||
it("rejects a non-boolean value with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ bp: "nope" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Case 4 — unauthenticated cross-user request ────────────────────
|
||||
// Marc's spec mentions "cross-user 404" — the route layer returns 401
|
||||
// when there is no session at all, which is the only "you are not the
|
||||
// right user" surface available before requireAuth() resolves an
|
||||
// identity. `prisma.user.update` must NOT fire in this case.
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ mood: false }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* v1.4.25 W6c — per-user Doctor-Report section toggles.
|
||||
*
|
||||
* GET /api/auth/me/doctor-report-prefs — returns the resolved prefs
|
||||
* (defaults when the row is null).
|
||||
* PUT /api/auth/me/doctor-report-prefs — merges the supplied partial
|
||||
* shape over the current row
|
||||
* and persists the canonical
|
||||
* fully-resolved object so a
|
||||
* future schema migration
|
||||
* doesn't need to back-fill
|
||||
* missing keys.
|
||||
*
|
||||
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
|
||||
* helper. The doctor-report aggregator + PDF renderer both read this
|
||||
* row at generation time so mood data is dropped at the aggregator
|
||||
* layer when `mood = false` — the data never leaves the DB.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
doctorReportPrefsSchema,
|
||||
parseDoctorReportPrefs,
|
||||
resolveDoctorReportPrefs,
|
||||
} from "@/lib/validations/doctor-report-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.doctor-report-prefs.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { doctorReportPrefsJson: true },
|
||||
});
|
||||
return apiSuccess(parseDoctorReportPrefs(row?.doctorReportPrefsJson));
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "doctor-report-prefs.body.invalid_json");
|
||||
}
|
||||
|
||||
const parsed = doctorReportPrefsSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.doctor-report-prefs.put.invalid" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
throw new HttpError(422, "doctor-report-prefs.body.invalid_shape");
|
||||
}
|
||||
|
||||
// Merge the partial input over the current row (or defaults) so a
|
||||
// dialog that only toggles `mood` doesn't have to re-state every other
|
||||
// flag. Persist the canonical fully-resolved object so the column
|
||||
// shape stays stable across future schema additions.
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { doctorReportPrefsJson: true },
|
||||
});
|
||||
const merged = resolveDoctorReportPrefs(
|
||||
current?.doctorReportPrefsJson,
|
||||
parsed.data,
|
||||
);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
// `merged` is a typed object; Prisma's `InputJsonValue` insists on an
|
||||
// index signature so cast through `unknown` rather than widen the
|
||||
// typed return surface. Shape is identical to the validated input.
|
||||
data: {
|
||||
doctorReportPrefsJson: merged as unknown as Record<string, boolean>,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.doctor-report-prefs.put" },
|
||||
meta: {
|
||||
bp: merged.bp,
|
||||
weight: merged.weight,
|
||||
pulse: merged.pulse,
|
||||
bmi: merged.bmi,
|
||||
mood: merged.mood,
|
||||
compliance: merged.compliance,
|
||||
sleep: merged.sleep,
|
||||
},
|
||||
});
|
||||
return apiSuccess(merged);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* v1.4.25 W6c — doctor-report section availability probe.
|
||||
*
|
||||
* Returns one boolean per toggleable section so the dialog can hide
|
||||
* checkboxes for data types that have zero rows in the user-selected
|
||||
* date range (Marc's "hide-when-empty" rule). Pure presence check —
|
||||
* `count` queries on small indexed tables — so this is cheap enough to
|
||||
* run on every date-range change in the dialog without driving cost.
|
||||
*
|
||||
* Body shape (mirrors `/api/doctor-report`):
|
||||
* { startDate?: ISO, endDate?: ISO, days?: number }
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { normaliseDateRange } from "@/lib/doctor-report-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export interface DoctorReportAvailability {
|
||||
/** Section flags — `true` when at least one row exists in the range. */
|
||||
bp: boolean;
|
||||
weight: boolean;
|
||||
pulse: boolean;
|
||||
/** BMI requires both a weight reading AND a configured height. */
|
||||
bmi: boolean;
|
||||
mood: boolean;
|
||||
/** Medication compliance — derived from `MedicationIntakeEvent` rows. */
|
||||
compliance: boolean;
|
||||
sleep: boolean;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "doctor-report.availability" } });
|
||||
|
||||
const { data: body, error } = await safeJson(request);
|
||||
if (error) return error;
|
||||
const range = normaliseDateRange(body ?? undefined);
|
||||
const { start, end } = range;
|
||||
|
||||
// `count` queries are cheaper than `findMany` for a presence check —
|
||||
// we never read the rows themselves. The user has indexes on
|
||||
// `(userId, measuredAt)`, `(userId, moodLoggedAt)`, and
|
||||
// `(userId, scheduledFor)` so these stay sub-millisecond even with
|
||||
// multi-year ranges.
|
||||
const [
|
||||
bpCount,
|
||||
weightCount,
|
||||
pulseCount,
|
||||
moodCount,
|
||||
complianceCount,
|
||||
sleepCount,
|
||||
profile,
|
||||
] = await Promise.all([
|
||||
prisma.measurement.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
measuredAt: { gte: start, lte: end },
|
||||
type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
|
||||
},
|
||||
}),
|
||||
prisma.measurement.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
measuredAt: { gte: start, lte: end },
|
||||
type: "WEIGHT",
|
||||
},
|
||||
}),
|
||||
prisma.measurement.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
measuredAt: { gte: start, lte: end },
|
||||
type: "PULSE",
|
||||
},
|
||||
}),
|
||||
prisma.moodEntry.count({
|
||||
where: { userId: user.id, moodLoggedAt: { gte: start, lte: end } },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.count({
|
||||
where: { userId: user.id, scheduledFor: { gte: start, lte: end } },
|
||||
}),
|
||||
prisma.measurement.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
measuredAt: { gte: start, lte: end },
|
||||
type: "SLEEP_DURATION",
|
||||
},
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { heightCm: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// BMI is derived (latest weight × profile height) so it only shows up
|
||||
// when BOTH inputs are available — otherwise the user would tick a
|
||||
// box that produces a silently-empty section.
|
||||
const bmi = weightCount > 0 && (profile?.heightCm ?? null) !== null;
|
||||
|
||||
const availability: DoctorReportAvailability = {
|
||||
bp: bpCount > 0,
|
||||
weight: weightCount > 0,
|
||||
pulse: pulseCount > 0,
|
||||
bmi,
|
||||
mood: moodCount > 0,
|
||||
compliance: complianceCount > 0,
|
||||
sleep: sleepCount > 0,
|
||||
};
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
report_days: range.days,
|
||||
sections_available: Object.values(availability).filter(Boolean).length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(availability);
|
||||
});
|
||||
@@ -1,14 +1,28 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Doctor-report export dialog (v1.4.15 phase B6).
|
||||
* Doctor-report export dialog (v1.4.15 phase B6 + v1.4.25 W6c).
|
||||
*
|
||||
* Wraps the trigger button so the user picks a reporting period and
|
||||
* (optionally) a practice / clinic name before the PDF is generated.
|
||||
* Wraps the trigger button so the user picks:
|
||||
* 1. Reporting period (date range, with three presets).
|
||||
* 2. Practice / clinic name (optional, persisted between exports).
|
||||
* 3. Which data sections appear in the PDF (per-user persisted toggles).
|
||||
*
|
||||
* Hide-when-empty (Marc 2026-05-14): a section's toggle is only shown
|
||||
* when the selected date range actually has data for it — checking a box
|
||||
* for an empty section would produce a silently-empty PDF page. The
|
||||
* availability probe runs on every range change via
|
||||
* `/api/doctor-report/availability`.
|
||||
*
|
||||
* Privacy default: mood is OFF by default per Marc — mental-health data
|
||||
* is opt-in even within a single user's own surface. The API layer
|
||||
* filters mood out of the report payload server-side when the toggle is
|
||||
* off, so the data never leaves the DB row.
|
||||
*
|
||||
* Defaults:
|
||||
* - end = today
|
||||
* - start = today − 90 days
|
||||
* - sections = persisted prefs OR documented defaults
|
||||
*
|
||||
* Validation runs in the client so the dialog can show inline errors
|
||||
* without bouncing through the server. The server still re-validates via
|
||||
@@ -16,7 +30,7 @@
|
||||
* messages are UX, not security.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -31,16 +45,53 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DateInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
type DoctorReportPrefs,
|
||||
} from "@/lib/validations/doctor-report-prefs";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
const ONE_DAY_MS = 86_400_000;
|
||||
const MAX_RANGE_DAYS = 730;
|
||||
const PRACTICE_NAME_MAX_LENGTH = 120;
|
||||
|
||||
/**
|
||||
* Order in which section toggles render. Matches the order the sections
|
||||
* appear in the generated PDF so the dialog's mental model lines up with
|
||||
* the printed artefact. `mood` is intentionally last because its
|
||||
* privacy-sensitive footnote needs prime visual placement.
|
||||
*/
|
||||
const SECTION_ORDER: ReadonlyArray<keyof DoctorReportPrefs> = [
|
||||
"bp",
|
||||
"weight",
|
||||
"pulse",
|
||||
"bmi",
|
||||
"compliance",
|
||||
"sleep",
|
||||
"mood",
|
||||
] as const;
|
||||
|
||||
interface SectionAvailability {
|
||||
bp: boolean;
|
||||
weight: boolean;
|
||||
pulse: boolean;
|
||||
bmi: boolean;
|
||||
mood: boolean;
|
||||
compliance: boolean;
|
||||
sleep: boolean;
|
||||
}
|
||||
|
||||
export interface DoctorReportSubmitPayload {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
practiceName: string | null;
|
||||
/**
|
||||
* Per-section toggles. The caller forwards this to
|
||||
* `/api/doctor-report` so the aggregator drops disabled sections
|
||||
* (mood specifically is filtered at the data layer for privacy).
|
||||
*/
|
||||
sections: DoctorReportPrefs;
|
||||
}
|
||||
|
||||
interface DoctorReportDialogProps {
|
||||
@@ -75,6 +126,19 @@ function defaultStartIso(): string {
|
||||
return formatLocalDate(d);
|
||||
}
|
||||
|
||||
function rangeIsoFromLocal(startDate: string, endDate: string): {
|
||||
startIso: string;
|
||||
endIso: string;
|
||||
} {
|
||||
// Send dates as ISO timestamps anchored to the local day's start/end
|
||||
// so an inclusive range `[YYYY-MM-DD, YYYY-MM-DD]` captures everything
|
||||
// logged on each boundary day.
|
||||
return {
|
||||
startIso: new Date(`${startDate}T00:00:00`).toISOString(),
|
||||
endIso: new Date(`${endDate}T23:59:59.999`).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function DoctorReportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -88,6 +152,22 @@ export function DoctorReportDialog({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Per-user section preferences. Loaded lazily when the dialog opens —
|
||||
// skips a roundtrip on every render of the export card.
|
||||
const [prefs, setPrefs] = useState<DoctorReportPrefs>(
|
||||
DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
);
|
||||
const [prefsLoaded, setPrefsLoaded] = useState(false);
|
||||
|
||||
// Section availability for the currently-selected range. `null` =
|
||||
// "we haven't probed yet" so the section group renders a skeleton row
|
||||
// rather than flashing an empty state.
|
||||
const [availability, setAvailability] = useState<SectionAvailability | null>(
|
||||
null,
|
||||
);
|
||||
const [availabilityLoading, setAvailabilityLoading] = useState(false);
|
||||
const availabilityRequestId = useRef(0);
|
||||
|
||||
// Reset on open: re-anchor dates + practice-name to defaults each time
|
||||
// the dialog appears so a follow-up export doesn't reuse the previous
|
||||
// custom range. Using the React-recommended "track-the-trigger" pattern
|
||||
@@ -106,6 +186,74 @@ export function DoctorReportDialog({
|
||||
setLastOpen(false);
|
||||
}
|
||||
|
||||
// Load persisted section prefs when the dialog opens for the first
|
||||
// time. Best-effort: a failure falls back to documented defaults so
|
||||
// the dialog stays usable even if the preferences endpoint is down.
|
||||
useEffect(() => {
|
||||
if (!open || prefsLoaded) return;
|
||||
let cancelled = false;
|
||||
fetch("/api/auth/me/doctor-report-prefs", { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((json) => {
|
||||
if (cancelled) return;
|
||||
const incoming = json?.data;
|
||||
if (incoming && typeof incoming === "object") {
|
||||
setPrefs({ ...DEFAULT_DOCTOR_REPORT_PREFS, ...incoming });
|
||||
}
|
||||
setPrefsLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPrefsLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, prefsLoaded]);
|
||||
|
||||
// Re-probe availability whenever the range changes. setState calls
|
||||
// live inside the promise handlers (microtask-deferred), satisfying
|
||||
// the strict `react-hooks/set-state-in-effect` rule while keeping the
|
||||
// loading indicator promptly visible. A generation counter prevents
|
||||
// a slow earlier response from stomping on a faster later response
|
||||
// (classic stale-write race).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!startDate || !endDate) return;
|
||||
const { startIso, endIso } = rangeIsoFromLocal(startDate, endDate);
|
||||
const requestId = ++availabilityRequestId.current;
|
||||
let cancelled = false;
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
if (cancelled) return null;
|
||||
setAvailabilityLoading(true);
|
||||
return fetch("/api/doctor-report/availability", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ startDate: startIso, endDate: endIso }),
|
||||
});
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (cancelled || !res || !res.ok) return;
|
||||
const json = (await res.json()) as { data: SectionAvailability };
|
||||
if (requestId !== availabilityRequestId.current) return;
|
||||
setAvailability(json.data);
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort — keep the previous availability snapshot.
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled && requestId === availabilityRequestId.current) {
|
||||
setAvailabilityLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, startDate, endDate]);
|
||||
|
||||
const validation = useMemo(() => {
|
||||
if (!startDate || !endDate) {
|
||||
return {
|
||||
@@ -146,6 +294,36 @@ export function DoctorReportDialog({
|
||||
setError(null);
|
||||
}
|
||||
|
||||
// Sections that have data in the current range. Drives both the
|
||||
// rendered checkbox list AND the canonical payload — toggles for
|
||||
// empty sections are stripped before submission so the server doesn't
|
||||
// see a "yes, render this empty thing" instruction.
|
||||
const activeSections = useMemo<Array<keyof DoctorReportPrefs>>(() => {
|
||||
if (!availability) return [];
|
||||
return SECTION_ORDER.filter((key) => availability[key]);
|
||||
}, [availability]);
|
||||
|
||||
function toggleSection(key: keyof DoctorReportPrefs, value: boolean) {
|
||||
setPrefs((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function persistPrefs(next: DoctorReportPrefs) {
|
||||
// Best-effort PUT — a network blip mustn't block PDF generation. The
|
||||
// current dialog still submits with the chosen `next` value either
|
||||
// way, so the user gets the PDF they asked for even if persistence
|
||||
// failed (they'll just see the defaults next time).
|
||||
try {
|
||||
await fetch("/api/auth/me/doctor-report-prefs", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!validation.ok) {
|
||||
@@ -155,12 +333,21 @@ export function DoctorReportDialog({
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Send dates as ISO timestamps anchored to the local day's
|
||||
// start/end so an inclusive range `[YYYY-MM-DD, YYYY-MM-DD]`
|
||||
// captures everything logged on each boundary day.
|
||||
const startIso = new Date(`${startDate}T00:00:00`).toISOString();
|
||||
const endIso = new Date(`${endDate}T23:59:59.999`).toISOString();
|
||||
const { startIso, endIso } = rangeIsoFromLocal(startDate, endDate);
|
||||
const trimmedPractice = practiceName.trim();
|
||||
|
||||
// Compose the section payload: keep the persisted prefs for keys
|
||||
// that ARE available in the range; force `false` for keys that
|
||||
// aren't (so the server never tries to render an empty section).
|
||||
const sections: DoctorReportPrefs = { ...DEFAULT_DOCTOR_REPORT_PREFS };
|
||||
for (const key of SECTION_ORDER) {
|
||||
sections[key] =
|
||||
(availability?.[key] ?? false) && prefs[key] === true;
|
||||
}
|
||||
|
||||
// Fire-and-forget persistence so the user doesn't wait on it.
|
||||
void persistPrefs(prefs);
|
||||
|
||||
await onSubmit({
|
||||
startDate: startIso,
|
||||
endDate: endIso,
|
||||
@@ -168,6 +355,7 @@ export function DoctorReportDialog({
|
||||
trimmedPractice.length > 0
|
||||
? trimmedPractice.slice(0, PRACTICE_NAME_MAX_LENGTH)
|
||||
: null,
|
||||
sections,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
@@ -263,6 +451,15 @@ export function DoctorReportDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Section toggles ──────────────────────────────────────── */}
|
||||
<SectionToggles
|
||||
activeSections={activeSections}
|
||||
availability={availability}
|
||||
availabilityLoading={availabilityLoading}
|
||||
prefs={prefs}
|
||||
onToggle={toggleSection}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -294,3 +491,119 @@ export function DoctorReportDialog({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────── Subviews ──────────────────────────────
|
||||
|
||||
interface SectionTogglesProps {
|
||||
activeSections: Array<keyof DoctorReportPrefs>;
|
||||
availability: SectionAvailability | null;
|
||||
availabilityLoading: boolean;
|
||||
prefs: DoctorReportPrefs;
|
||||
onToggle: (key: keyof DoctorReportPrefs, value: boolean) => void;
|
||||
}
|
||||
|
||||
const SECTION_LABEL_KEYS: Record<keyof DoctorReportPrefs, string> = {
|
||||
bp: "doctorReport.sections.bp",
|
||||
weight: "doctorReport.sections.weight",
|
||||
pulse: "doctorReport.sections.pulse",
|
||||
bmi: "doctorReport.sections.bmi",
|
||||
mood: "doctorReport.sections.mood",
|
||||
compliance: "doctorReport.sections.compliance",
|
||||
sleep: "doctorReport.sections.sleep",
|
||||
};
|
||||
|
||||
function SectionToggles({
|
||||
activeSections,
|
||||
availability,
|
||||
availabilityLoading,
|
||||
prefs,
|
||||
onToggle,
|
||||
}: SectionTogglesProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// First open: show a quiet skeleton row instead of the full toggle
|
||||
// list. Keeps the dialog from "flashing" between empty and populated
|
||||
// states while the probe is in-flight.
|
||||
if (availability === null && availabilityLoading) {
|
||||
return (
|
||||
<div
|
||||
className="border-border bg-muted/30 space-y-2 rounded-lg border p-3"
|
||||
data-testid="doctor-report-sections-loading"
|
||||
>
|
||||
<p className="text-xs font-medium tracking-wide uppercase text-muted-foreground">
|
||||
{t("doctorReport.sections.title")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted h-9 animate-pulse rounded-md" />
|
||||
<div className="bg-muted h-9 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No data in the selected range — render an empty-state hint so the
|
||||
// user understands why the toggle list is missing. The PDF still
|
||||
// generates (cover + period + an empty body) so they get something.
|
||||
if (availability && activeSections.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="border-border bg-muted/30 rounded-lg border p-3"
|
||||
data-testid="doctor-report-sections-empty"
|
||||
>
|
||||
<p className="text-xs font-medium tracking-wide uppercase text-muted-foreground">
|
||||
{t("doctorReport.sections.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1.5 text-xs leading-relaxed">
|
||||
{t("doctorReport.sections.empty")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-border bg-muted/30 space-y-3 rounded-lg border p-3"
|
||||
data-testid="doctor-report-sections"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-xs font-medium tracking-wide uppercase text-muted-foreground">
|
||||
{t("doctorReport.sections.title")}
|
||||
</p>
|
||||
{availabilityLoading && (
|
||||
<Loader2 className="text-muted-foreground h-3 w-3 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<ul className="grid grid-cols-1 gap-1 sm:grid-cols-2">
|
||||
{activeSections.map((key) => {
|
||||
const id = `dr-section-${key}`;
|
||||
return (
|
||||
<li key={key} className="min-h-11">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="hover:bg-muted/50 -mx-2 flex cursor-pointer items-center justify-between gap-3 rounded-md px-2 py-1.5 transition-colors"
|
||||
>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-medium leading-tight">
|
||||
{t(SECTION_LABEL_KEYS[key])}
|
||||
</span>
|
||||
{key === "mood" && (
|
||||
<span className="text-muted-foreground text-[11px] leading-tight">
|
||||
{t("doctorReport.sections.moodSensitive")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Switch
|
||||
id={id}
|
||||
data-testid={`doctor-report-section-${key}`}
|
||||
size="sm"
|
||||
checked={prefs[key]}
|
||||
onCheckedChange={(checked) => onToggle(key, checked)}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -183,6 +183,10 @@ function DoctorReportCard() {
|
||||
startDate: payload.startDate,
|
||||
endDate: payload.endDate,
|
||||
practiceName: payload.practiceName,
|
||||
// v1.4.25 W6c — per-section toggles. The aggregator drops
|
||||
// disabled sections (mood is filtered server-side so the
|
||||
// data never leaves the DB row).
|
||||
sections: payload.sections,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
Reference in new issue
Block a user