diff --git a/messages/de.json b/messages/de.json index 944e943ca..8041ed5e8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index ba5ad7731..f23dd7fb4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts new file mode 100644 index 000000000..aa822ea71 --- /dev/null +++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts @@ -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)( + 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)(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)( + 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)( + 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)( + mkPut({ mood: false }), + ); + expect(res.status).toBe(401); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/doctor-report-prefs/route.ts b/src/app/api/auth/me/doctor-report-prefs/route.ts new file mode 100644 index 000000000..cdb8728ab --- /dev/null +++ b/src/app/api/auth/me/doctor-report-prefs/route.ts @@ -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, + }, + }); + + 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); +}); diff --git a/src/app/api/doctor-report/availability/route.ts b/src/app/api/doctor-report/availability/route.ts new file mode 100644 index 000000000..ec175f797 --- /dev/null +++ b/src/app/api/doctor-report/availability/route.ts @@ -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); +}); diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx index 9f9417fc2..30ceea2ef 100644 --- a/src/components/doctor-report/doctor-report-dialog.tsx +++ b/src/components/doctor-report/doctor-report-dialog.tsx @@ -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 = [ + "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(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( + 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( + 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>(() => { + 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) { 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({ /> + {/* ── Section toggles ──────────────────────────────────────── */} + + {error && (

); } + +// ────────────────────────────── Subviews ────────────────────────────── + +interface SectionTogglesProps { + activeSections: Array; + availability: SectionAvailability | null; + availabilityLoading: boolean; + prefs: DoctorReportPrefs; + onToggle: (key: keyof DoctorReportPrefs, value: boolean) => void; +} + +const SECTION_LABEL_KEYS: Record = { + 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 ( +

+

+ {t("doctorReport.sections.title")} +

+
+
+
+
+
+ ); + } + + // 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 ( +
+

+ {t("doctorReport.sections.title")} +

+

+ {t("doctorReport.sections.empty")} +

+
+ ); + } + + return ( +
+
+

+ {t("doctorReport.sections.title")} +

+ {availabilityLoading && ( + + )} +
+
    + {activeSections.map((key) => { + const id = `dr-section-${key}`; + return ( +
  • + +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index a5908f75d..cf26975e6 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -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) {