Merge pull request #502 from MBombeck/release/v1.28.45

release/v1.28.45 — doctor-report + privacy fixes
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-16 06:01:21 +02:00
commit 8dc04bc4b2
16 files changed
+213 -19

No files matched your search

+12
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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,
@@ -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", () => {
@@ -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",
@@ -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(),
+14
View File
@@ -211,6 +211,9 @@ const CORE_CLUSTERS: ReadonlySet<CoachDataCluster> = new Set<CoachDataCluster>([
* 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<Record<ModuleKey, CoachScopeSource[]>> =
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",
],
};
/**
@@ -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<string, boolean>;
};
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<string, boolean>;
};
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(
+12 -6
View File
@@ -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<string, keyof DoctorReportPrefs> = {
BLOOD_PRESSURE_SYS: "bp",
+17 -6
View File
@@ -183,8 +183,12 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
* 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<number, string> = {
@@ -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),
]);
}
@@ -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,
@@ -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);
});
});
@@ -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,
+8 -1
View File
@@ -94,13 +94,19 @@ export type ExportSections = z.infer<typeof exportSectionsSchema>;
* 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,