mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #502 from MBombeck/release/v1.28.45
release/v1.28.45 — doctor-report + privacy fixes
This commit is contained in:
16 files changed
+213
-19
No files matched your search
@@ -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,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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user