diff --git a/CHANGELOG.md b/CHANGELOG.md index bf579d64c..bd9a1a7ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +## [1.28.45] — 2026-07-16 — Doctor report + privacy fixes + +- Sleep now actually appears in the doctor-report PDF. The sleep section was + enabled by default and its data was prepared, but the PDF's vitals table left + it out, so the section rendered empty — it now shows time asleep in hours. +- Glucose can now be included or withheld from a shared report like every other + clinical section. The toggle existed in settings but did nothing; it now + gates the glucose data end to end. +- Turning the environment module off now also keeps its data (audio exposure, + daylight, skin temperature) out of the coach, matching how the other modules + behave. + ## [1.28.44] — 2026-07-16 — Loose ends: a broken link, the API contract, module guards - The "connect a device" link on the sleep insights empty state pointed at a diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 69e11adc9..5b2e5dfaf 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.44 + version: 1.28.45 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index c2d767791..9f9fc92ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.44", + "version": "1.28.45", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index cc5416e87..f0792acaf 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.44"; + /* @sw-version-fallback */ "v1.28.45"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` 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 index c8ebc622e..b5c5a5584 100644 --- 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 @@ -90,6 +90,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { mood: true, compliance: true, sleep: false, + glucose: true, cycle: false, labs: true, allergies: true, @@ -116,6 +117,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { mood: true, compliance: false, sleep: false, + glucose: false, cycle: false, labs: false, allergies: false, diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 22cc7601f..183deca8a 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -173,6 +173,47 @@ describe("renderDoctorReportPdfBytes", () => { const header = String.fromCharCode(...bytes.slice(0, 5)); expect(header).toBe("%PDF-"); }); + + it("renders a sleep vitals row (minutes → hours) when sleep stats exist", async () => { + // SLEEP_DURATION stats are per-night asleep totals in MINUTES; the vitals + // table renders them in hours. 420 min → 7 h, 450 min avg → 7.5 h. + const data = makeData({ + stats: { + ...makeData().stats, + SLEEP_DURATION: { + avg: 450, + min: 390, + max: 510, + count: 7, + latest: 420, + }, + }, + }); + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + // The sleep label and the hours-converted value both appear in the table. + // `num` renders one fixed fraction digit: 420 min → 7.0 h, 450 min → 7.5 h. + expect(text).toContain("Sleep duration"); + expect(text).toContain("7.0 h"); + expect(text).toContain("7.5 h"); + // The raw minute value must NOT leak into the table. + expect(text).not.toContain("420 h"); + expect(text).not.toContain("450 h"); + }); + + it("omits the sleep vitals row when there are no sleep stats", async () => { + const bytes = renderDoctorReportPdfBytes(makeData(), { + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + expect(text).not.toContain("Sleep duration"); + }); }); describe("buildDoctorReportPdfDocument", () => { diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index d4869a3cc..63fb742a3 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -120,8 +120,10 @@ describe("measurementTypeEnum coverage", () => { // Documented exclusions from the doctor-report main vitals table: // - BLOOD_GLUCOSE renders through the per-context `glucoseStats` section - // - SLEEP_DURATION + ACTIVITY_STEPS are intentionally omitted from the - // clinical PDF (lifestyle, not a vital sign — see source comment). + // - ACTIVITY_STEPS is intentionally omitted from the clinical PDF + // (lifestyle, not a vital sign — see source comment). SLEEP_DURATION + // DOES render in the vitals table (per-night asleep hours), gated on the + // default-ON `sleep` section toggle — see doctor-report-pdf-core.ts. // - v1.4.23 Apple Health metrics (HRV, resting HR, active energy, // flights, distance, VO2 max, body temperature) are excluded from // the v1.4.23 release of the doctor PDF — they ship into the @@ -136,7 +138,6 @@ describe("measurementTypeEnum coverage", () => { // doctor-report-pdf-core.ts so the rationale stays discoverable. const PDF_VITAL_EXCLUSIONS = new Set([ "BLOOD_GLUCOSE", - "SLEEP_DURATION", "ACTIVITY_STEPS", "HEART_RATE_VARIABILITY", "RESTING_HEART_RATE", diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index 1e74d4c66..ce3b5cef1 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -635,6 +635,45 @@ describe("buildCoachSnapshot", () => { expect(out.provenance.metrics).not.toContain("glucose"); }); + it("drops the environment/exposure blocks when the environment module is disabled", async () => { + // The opt-in environment cluster owns the audio-exposure / daylight / + // skin-temperature sources. With the module off, none may reach the model. + resolveModuleMapMock.mockResolvedValue({ + ...allModulesEnabled(), + environment: false, + }); + featuresMock.mockResolvedValue({}); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(2, 72, "AUDIO_EXPOSURE_ENV"), + daysAgo(3, 33.1, "SKIN_TEMPERATURE"), + daysAgo(4, 45, "TIME_IN_DAYLIGHT"), + ]); + + const envSources = [ + "audio_env", + "audio_headphone", + "audio_event", + "daylight", + "skin_temp", + ] as const; + const out = await buildCoachSnapshot("user-1", { + sources: [...envSources], + }); + const parsed = JSON.parse(out.snapshotJson); + + // No environment block reaches the prompt. + expect(parsed.audioExposureEnvironment).toBeUndefined(); + expect(parsed.audioExposureHeadphone).toBeUndefined(); + expect(parsed.audioExposureEvent).toBeUndefined(); + expect(parsed.timeInDaylight).toBeUndefined(); + expect(parsed.skinTemperature).toBeUndefined(); + // Every environment source token is stripped from scope. + for (const src of envSources) { + expect(parsed.scope.sources).not.toContain(src); + expect(out.provenance.metrics).not.toContain(src); + } + }); + it("drops the sleep + sleepRhythm blocks when the sleep module is disabled", async () => { resolveModuleMapMock.mockResolvedValue({ ...allModulesEnabled(), diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index f57e88c1a..56b627f8b 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -211,6 +211,9 @@ const CORE_CLUSTERS: ReadonlySet = new Set([ * tokens drops the raw additive timelines too; the * composites are additionally gated below so they never * build off the sleep signal alone. + * - `environment` → the audio-exposure (env / headphone / event), daylight, + * and skin-temperature blocks — the sources the opt-in + * environment cluster owns (`CLUSTER_SOURCES.environment`). * * `cycle` is intentionally absent: its block already resolves through the * fully two-layer cycle gate (`isCycleAvailableForUser` — the per-user @@ -226,6 +229,17 @@ const MODULE_EXCLUDED_SOURCES: Partial> = glucose: ["glucose"], workouts: ["workouts"], recovery: ["hrv", "resting_hr", "vo2_max"], + // The environment/exposure cluster owns exactly these sources (mirrors + // `CLUSTER_SOURCES.environment` in clusters.ts). When the opt-in + // environment module is off, strip its audio-exposure / daylight / + // skin-temperature blocks so the model never sees a disabled domain. + environment: [ + "audio_env", + "audio_headphone", + "audio_event", + "daylight", + "skin_temp", + ], }; /** diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts index 48aac4355..f5dc1a1cf 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -306,6 +306,36 @@ describe("loadShareViewData — grouped sections are folded, not silently defaul expect(opts.sections.labs).toBe(true); }); + it("folds a grouped glucose=OFF toggle down to sections.glucose=false", async () => { + // A diabetic owner withholds glucose from THIS share without disabling the + // glucose module app-wide. The grouped `glucose` toggle must survive the + // fold and reach the aggregator so no glucose panel is built. + await loadShareViewData( + ctx({ + sectionsJson: { + vitals: { bp: true }, + glucose: false, + }, + documentOnly: false, + }), + ); + const opts = collect.mock.calls[0]![2] as { + sections: Record; + }; + expect(opts.sections.glucose).toBe(false); + // A share that leaves glucose unspecified keeps the default-ON behaviour. + vi.clearAllMocks(); + collect.mockResolvedValue({ patient: { displayName: "Shared record" } }); + findDocs.mockResolvedValue([]); + await loadShareViewData( + ctx({ sectionsJson: { vitals: { bp: true } }, documentOnly: false }), + ); + const opts2 = collect.mock.calls[0]![2] as { + sections: Record; + }; + expect(opts2.sections.glucose).toBe(true); + }); + it("still resolves a flat legacy blob through the flat parser unchanged", async () => { // A flat shape has no grouped-only key, so it keeps the exact legacy path. await loadShareViewData( diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 8f533c90b..8f7e0d904 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -104,11 +104,15 @@ export async function collectDoctorReportData( // bmi/compliance) carry no key and are never gated here. if (moduleMap.mood === false) sections.mood = false; if (moduleMap.sleep === false) sections.sleep = false; + if (moduleMap.glucose === false) sections.glucose = false; if (moduleMap.cycle === false) sections.cycle = false; if (moduleMap.labs === false) sections.labs = false; // Module gates with no `sections` key — applied directly to the data slices - // below (glucose panel, recovery/strain wellness scores, workout series). - const glucoseEnabled = moduleMap.glucose !== false; + // below (recovery/strain wellness scores, workout series). Glucose now + // carries its own report section toggle, into which the module gate above + // already folds — so the section flag is the single lever for the glucose + // panel (stats + ranges + clinical metrics). + const glucoseEnabled = sections.glucose !== false; const recoveryEnabled = moduleMap.recovery !== false; const workoutsEnabled = moduleMap.workouts !== false; @@ -140,6 +144,8 @@ export async function collectDoctorReportData( } if (sections.pulse === false) excludedMeasurementTypes.push("PULSE"); if (sections.sleep === false) excludedMeasurementTypes.push("SLEEP_DURATION"); + if (sections.glucose === false) + excludedMeasurementTypes.push("BLOOD_GLUCOSE"); for (const [type, moduleKey] of Object.entries(MEASUREMENT_TYPE_MODULE)) { if (moduleMap[moduleKey] === false) { excludedMeasurementTypes.push(type as MeasurementType); @@ -924,10 +930,10 @@ export async function collectDoctorReportData( /** * Per-measurement-type → section-toggle mapping. Keys not listed here - * (e.g., glucose contexts, body fat, oxygen saturation) bypass the - * toggles for now — those sections will get their own flags in a - * future iteration. The current scope per the maintainer's directive is the - * "big five" plus mood + compliance. + * (e.g., body fat, oxygen saturation) bypass the toggles — those sections + * have no per-report flag. Glucose DOES carry a `glucose` section toggle, + * but `BLOOD_GLUCOSE` is gated at query-exclusion time (mirroring sleep) and + * its panel collapses via `glucoseEnabled`, so it does not ride this map. */ const MEASUREMENT_TYPE_SECTION: Record = { BLOOD_PRESSURE_SYS: "bp", diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 2f76a1905..a4a06623b 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -183,8 +183,12 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { * smart scales report all three together. SpO2 (Withings ScanWatch type * 54, HealthKit, n8n / Health Connect) is rendered last in the same * table for clinical readability. Glucose ships separately via - * per-context `glucoseStats`. Sleep + activity are intentionally - * excluded from a clinical-focused report. + * per-context `glucoseStats`. SLEEP_DURATION rides this table too: the + * `sleep` section toggle is default-ON and the data layer reconstructs a + * per-night time-asleep total (in minutes) for exactly this row — the + * render loop converts it to hours so value and unit (`h`) agree, matching + * the FHIR sleep Observation. Activity (steps) stays excluded from a + * clinical-focused report. * * v1.10.0 — computed scores (WX-C). The server-derived `*_SCORE` types * (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE) are NOT clinical vitals: @@ -216,6 +220,7 @@ export const DOCTOR_REPORT_VITAL_TYPES = [ "TOTAL_BODY_WATER", "BONE_MASS", "OXYGEN_SATURATION", + "SLEEP_DURATION", ] as const; const MOOD_LABEL_KEYS: Record = { @@ -689,12 +694,18 @@ export function buildDoctorReportPdfDocument( const s = data.stats[type]; if (!s) continue; const unit = unitFor(type); + // SLEEP_DURATION stats are per-night asleep totals in MINUTES (the data + // layer reconstructs them for exactly this row); the vitals unit is hours + // (`h`), so convert to keep value and unit consistent — the same hours + // value the FHIR sleep Observation emits. + const conv = + type === "SLEEP_DURATION" ? (v: number) => v / 60 : (v: number) => v; vitalRows.push([ t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? ""), - `${num(s.latest)} ${unit}`.trim(), - `${num(s.avg)} ${unit}`.trim(), - num(s.min), - num(s.max), + `${num(conv(s.latest))} ${unit}`.trim(), + `${num(conv(s.avg))} ${unit}`.trim(), + num(conv(s.min)), + num(conv(s.max)), String(s.count), ]); } diff --git a/src/lib/validations/__tests__/doctor-report-prefs.test.ts b/src/lib/validations/__tests__/doctor-report-prefs.test.ts index 59cbb0950..c3abd34fc 100644 --- a/src/lib/validations/__tests__/doctor-report-prefs.test.ts +++ b/src/lib/validations/__tests__/doctor-report-prefs.test.ts @@ -57,6 +57,18 @@ describe("parseDoctorReportPrefs", () => { expect(out.mood).toBe(false); }); + it("defaults glucose to ON (per-report control, parity with pre-toggle behaviour)", () => { + const out = parseDoctorReportPrefs(null); + expect(out.glucose).toBe(true); + }); + + it("accepts an explicit glucose=false partial update", () => { + const out = parseDoctorReportPrefs({ glucose: false }); + expect(out.glucose).toBe(false); + // Other sections keep their defaults. + expect(out.labs).toBe(true); + }); + it("defaults labs to ON (recorded to share with a clinician)", () => { const out = parseDoctorReportPrefs(null); expect(out.labs).toBe(true); @@ -90,6 +102,7 @@ describe("resolveDoctorReportPrefs", () => { mood: true, compliance: false, sleep: false, + glucose: false, cycle: false, labs: false, allergies: false, diff --git a/src/lib/validations/__tests__/health-record-export.test.ts b/src/lib/validations/__tests__/health-record-export.test.ts index a859b1a59..d79ad1472 100644 --- a/src/lib/validations/__tests__/health-record-export.test.ts +++ b/src/lib/validations/__tests__/health-record-export.test.ts @@ -104,4 +104,13 @@ describe("toDoctorReportPrefs", () => { toDoctorReportPrefs({ medications: { compliance: false } }).compliance, ).toBe(false); }); + + it("threads the glucose section toggle (default ON, off when unchecked)", () => { + // Default-on preserves the pre-toggle behaviour where glucose always + // rendered; an explicit false withholds glucose from this report. + expect(toDoctorReportPrefs(undefined).glucose).toBe(true); + expect(toDoctorReportPrefs({}).glucose).toBe(true); + expect(toDoctorReportPrefs({ glucose: true }).glucose).toBe(true); + expect(toDoctorReportPrefs({ glucose: false }).glucose).toBe(false); + }); }); diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts index c4a0daddb..2da3c2075 100644 --- a/src/lib/validations/doctor-report-prefs.ts +++ b/src/lib/validations/doctor-report-prefs.ts @@ -31,6 +31,7 @@ export const doctorReportPrefsSchema = z mood: z.boolean(), compliance: z.boolean(), sleep: z.boolean(), + glucose: z.boolean(), cycle: z.boolean(), labs: z.boolean(), allergies: z.boolean(), @@ -53,6 +54,7 @@ export interface DoctorReportPrefs { mood: boolean; compliance: boolean; sleep: boolean; + glucose: boolean; cycle: boolean; labs: boolean; allergies: boolean; @@ -73,6 +75,12 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { mood: false, // privacy default per the maintainer compliance: true, sleep: true, + // Glucose the user recorded to share with a clinician — ON by default, + // like BP / weight / labs. The per-report toggle lets a diabetic user + // withhold glucose from a SPECIFIC shared report without disabling the + // glucose module app-wide (default-on preserves the pre-toggle behaviour, + // where glucose always rendered). + glucose: true, // Cycle data is opt-in: a user sharing a BP report with a cardiologist // should not auto-leak reproductive data. Same privacy stance as mood. cycle: false, @@ -102,6 +110,7 @@ export const EMPTY_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { mood: false, compliance: false, sleep: false, + glucose: false, cycle: false, labs: false, allergies: false, diff --git a/src/lib/validations/health-record-export.ts b/src/lib/validations/health-record-export.ts index 7563e0e82..37538d4d2 100644 --- a/src/lib/validations/health-record-export.ts +++ b/src/lib/validations/health-record-export.ts @@ -94,13 +94,19 @@ export type ExportSections = z.infer; * signal that a persisted `sectionsJson` blob is the grouped shape rather than * the flat one — the flat shape carries `bp` / `weight` / `pulse` at the top * level, the grouped shape nests them under `vitals`. + * + * `glucose` is deliberately NOT a discriminator: it is a top-level boolean in + * BOTH shapes (the flat `DoctorReportPrefs` gained its own `glucose` section + * toggle), so its presence no longer distinguishes grouped from flat. A real + * grouped export always carries one of the nested groups below, so detection + * stays reliable without it — and a bare `{ glucose: false }` blob correctly + * resolves through the flat parser. */ const GROUPED_SECTION_KEYS = [ "vitals", "cardioFitness", "activity", "medications", - "glucose", ] as const; /** @@ -187,6 +193,7 @@ export function toDoctorReportPrefs( mood: s.mood === true, compliance: m.compliance ?? m.list ?? fallback.compliance, sleep: a.sleep ?? fallback.sleep, + glucose: s.glucose ?? fallback.glucose, // Cycle is opt-in: only true when explicitly requested (privacy). cycle: s.cycle === true, labs: s.labs ?? fallback.labs,