mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
merge(modules): exclude disabled-module sections from doctor report and FHIR
This commit is contained in:
commit
7982f90cc1
2 files changed
+247
-3
No files matched your search
@@ -36,6 +36,7 @@ import {
|
||||
type DoctorReportPrefs,
|
||||
} from "@/lib/validations/doctor-report-prefs";
|
||||
import { buildCycleExportSummary } from "@/lib/cycle/export-data";
|
||||
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
lastNonSkippedTakenAt,
|
||||
@@ -483,6 +484,18 @@ export interface CollectDoctorReportOptions {
|
||||
* don't accidentally print a section the user opted out of.
|
||||
*/
|
||||
sections?: Partial<DoctorReportPrefs>;
|
||||
/**
|
||||
* v1.18.0 — resolved per-user module enable/disable map. When omitted the
|
||||
* aggregator resolves it itself via `resolveModuleMap(userId)`. A disabled
|
||||
* module forces its report section / FHIR resources out of the payload,
|
||||
* regardless of the user's per-report `sections` toggles: a user who turned
|
||||
* off the Sleep module exports no sleep series, mood Observations, glucose
|
||||
* panel, recovery/strain scores, cycle summary, or lab results for the
|
||||
* modules they no longer keep. Core clinical sections (weight / BP / pulse /
|
||||
* medications) carry no module key and are always included. Injectable for
|
||||
* tests so the module ↔ section mapping can be asserted without a DB.
|
||||
*/
|
||||
moduleMap?: Record<ModuleKey, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -602,10 +615,33 @@ export async function collectDoctorReportData(
|
||||
// either way (the queries are cheap on indexed columns) and stripped
|
||||
// from the returned payload below — keeping a single source of
|
||||
// truth for the data shape regardless of toggle state.
|
||||
// v1.18.0 — resolve the per-user module map up-front (once per build). A
|
||||
// disabled module's section/resources are excluded from the export so the
|
||||
// PDF + FHIR bundle reflect only the modules the user keeps. The map is
|
||||
// ANDed over the user's per-report `sections` toggles: a disabled module
|
||||
// forces its section off even if the report toggle says on (you can't share
|
||||
// data for a module you turned off), while leaving the others untouched.
|
||||
// Core clinical sections (weight / BP / pulse / medications) have no module
|
||||
// key and stay unconditional. Injectable for tests.
|
||||
const moduleMap =
|
||||
options.moduleMap ?? (await resolveModuleMap(userId));
|
||||
|
||||
const sections: DoctorReportPrefs = {
|
||||
...DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
...(options.sections ?? {}),
|
||||
};
|
||||
// Module gate: a disabled module wins over the report toggle. Each report
|
||||
// section maps to its owning module key; core sections (bp/weight/pulse/
|
||||
// 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.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;
|
||||
const recoveryEnabled = moduleMap.recovery !== false;
|
||||
const workoutsEnabled = moduleMap.workouts !== false;
|
||||
|
||||
const [measurements, medications, intakeEvents, moodEntries, userProfile] =
|
||||
await Promise.all([
|
||||
@@ -916,7 +952,12 @@ export async function collectDoctorReportData(
|
||||
// Per-context glucose stats + effective ranges (canonical mg/dL).
|
||||
const glucoseStats: Record<string, DoctorReportStats> = {};
|
||||
const glucoseRanges: Record<string, { min: number; max: number }> = {};
|
||||
const glucoseRows = measurements.filter((m) => m.type === "BLOOD_GLUCOSE");
|
||||
// v1.18.0 — when the glucose module is disabled, no glucose row enters the
|
||||
// panel: stats, ranges, and the clinical metrics all collapse to empty so the
|
||||
// PDF + FHIR Observations carry no glucose for this user.
|
||||
const glucoseRows = glucoseEnabled
|
||||
? measurements.filter((m) => m.type === "BLOOD_GLUCOSE")
|
||||
: [];
|
||||
const overrides = (userProfile?.thresholdsJson ??
|
||||
null) as ThresholdOverridesJson | null;
|
||||
const profileForRange = {
|
||||
@@ -966,8 +1007,8 @@ export async function collectDoctorReportData(
|
||||
// `stats`, and `compliance` lets the PDF renderer treat "section
|
||||
// disabled" identically to "section had no rows" — both paths skip
|
||||
// the table. Mood is already null when disabled (we never queried).
|
||||
const filteredByType = filterMeasurementKeys(byType, sections);
|
||||
const filteredStats = filterMeasurementKeys(stats, sections);
|
||||
const filteredByType = filterMeasurementKeys(byType, sections, moduleMap);
|
||||
const filteredStats = filterMeasurementKeys(stats, sections, moduleMap);
|
||||
const filteredCompliance = sections.compliance ? compliance : {};
|
||||
const filteredBmi = sections.bmi ? bmi : null;
|
||||
|
||||
@@ -1085,6 +1126,19 @@ export async function collectDoctorReportData(
|
||||
// series. Resolve to the canonical row per day (WHOOP wins) so the PDF reads
|
||||
// the SAME value the tile + iOS feed show — one number, one engine.
|
||||
const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => {
|
||||
// v1.18.0 — module gate per score type. Recovery + stress are the
|
||||
// recovery/readiness signals (recovery module); strain is the
|
||||
// training-load signal derived from the workouts table (workouts module).
|
||||
// A disabled owning module drops the score from the wellness summary and,
|
||||
// since the FHIR exporter sources these Observations from
|
||||
// `data.wellnessScores`, from the bundle too.
|
||||
if (type === "STRAIN_SCORE" && !workoutsEnabled) return [];
|
||||
if (
|
||||
(type === "RECOVERY_SCORE" || type === "STRESS_SCORE") &&
|
||||
!recoveryEnabled
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (type === "RECOVERY_SCORE") {
|
||||
const summary = summariseCanonicalRecovery(measurements, reportTz);
|
||||
return summary ? [summary] : [];
|
||||
@@ -1234,14 +1288,52 @@ const MEASUREMENT_TYPE_SECTION: Record<string, keyof DoctorReportPrefs> = {
|
||||
SLEEP_DURATION: "sleep",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.18.0 — per-measurement-type → owning module mapping for the types that
|
||||
* carry no `sections` toggle but DO belong to a toggleable module. When the
|
||||
* module is disabled, the type is stripped from `measurements` + `stats` so the
|
||||
* series never reaches the PDF tables or a FHIR Observation.
|
||||
*
|
||||
* - `workouts` owns the activity / movement series (steps, active energy,
|
||||
* distance, flights, the walking-gait + stair metrics) and the
|
||||
* training-load `STRAIN_SCORE`.
|
||||
* - `recovery` owns the readiness signals `RECOVERY_SCORE` + `STRESS_SCORE`.
|
||||
* - `glucose` owns the raw `BLOOD_GLUCOSE` series (the glucose panel itself is
|
||||
* gated where it is assembled; this strips the raw rows too).
|
||||
*
|
||||
* Core clinical types (BP / weight / pulse / body-composition / vitals) carry
|
||||
* no module and are never gated here. `sleep` / `mood` / `cycle` / `labs` ride
|
||||
* the `sections` mechanism instead.
|
||||
*/
|
||||
const MEASUREMENT_TYPE_MODULE: Record<string, ModuleKey> = {
|
||||
ACTIVITY_STEPS: "workouts",
|
||||
ACTIVE_ENERGY_BURNED: "workouts",
|
||||
FLIGHTS_CLIMBED: "workouts",
|
||||
WALKING_RUNNING_DISTANCE: "workouts",
|
||||
WALKING_SPEED: "workouts",
|
||||
WALKING_ASYMMETRY: "workouts",
|
||||
WALKING_STEP_LENGTH: "workouts",
|
||||
WALKING_DOUBLE_SUPPORT: "workouts",
|
||||
SIX_MINUTE_WALK_DISTANCE: "workouts",
|
||||
STAIR_ASCENT_SPEED: "workouts",
|
||||
STAIR_DESCENT_SPEED: "workouts",
|
||||
STRAIN_SCORE: "workouts",
|
||||
RECOVERY_SCORE: "recovery",
|
||||
STRESS_SCORE: "recovery",
|
||||
BLOOD_GLUCOSE: "glucose",
|
||||
};
|
||||
|
||||
function filterMeasurementKeys<T>(
|
||||
map: Record<string, T>,
|
||||
sections: DoctorReportPrefs,
|
||||
moduleMap: Record<ModuleKey, boolean>,
|
||||
): Record<string, T> {
|
||||
const out: Record<string, T> = {};
|
||||
for (const [type, value] of Object.entries(map)) {
|
||||
const sectionKey = MEASUREMENT_TYPE_SECTION[type];
|
||||
if (sectionKey && sections[sectionKey] === false) continue;
|
||||
const moduleKey = MEASUREMENT_TYPE_MODULE[type];
|
||||
if (moduleKey && moduleMap[moduleKey] === false) continue;
|
||||
out[type] = value;
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -110,9 +110,60 @@ async function seedUserWithEveryDataType(username: string): Promise<string> {
|
||||
unit: "bpm",
|
||||
measuredAt,
|
||||
},
|
||||
// v1.18.0 module-gated data types (no `sections` toggle of their own).
|
||||
{
|
||||
userId: user.id,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 95,
|
||||
unit: "mg/dL",
|
||||
glucoseContext: "FASTING",
|
||||
measuredAt,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "RECOVERY_SCORE",
|
||||
value: 72,
|
||||
unit: "score",
|
||||
source: "WHOOP",
|
||||
measuredAt,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "STRESS_SCORE",
|
||||
value: 40,
|
||||
unit: "score",
|
||||
measuredAt,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "STRAIN_SCORE",
|
||||
value: 12,
|
||||
unit: "score",
|
||||
measuredAt,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 8200,
|
||||
unit: "count",
|
||||
measuredAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Lab result — module `labs` owns these; the `labs` section toggle is ON by
|
||||
// default so a present `labs` module surfaces the panel.
|
||||
await prisma.labResult.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
panel: "Lipids",
|
||||
analyte: "LDL",
|
||||
value: 110,
|
||||
unit: "mg/dL",
|
||||
takenAt: measuredAt,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
@@ -249,6 +300,107 @@ describe("doctor-report — per-section toggles", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor-report — module enable/disable gating", () => {
|
||||
// A fully-enabled module map (the default-on shape `resolveModuleMap` would
|
||||
// return for an account that never toggled a module off).
|
||||
const ALL_ON = {
|
||||
cycle: true,
|
||||
mood: true,
|
||||
sleep: true,
|
||||
glucose: true,
|
||||
workouts: true,
|
||||
recovery: true,
|
||||
labs: true,
|
||||
achievements: true,
|
||||
coach: true,
|
||||
insights: true,
|
||||
doctorReport: true,
|
||||
} as const;
|
||||
|
||||
it("includes every module's data when all modules are enabled", async () => {
|
||||
const userId = await seedUserWithEveryDataType("dr-modules-all-on");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE, {
|
||||
// Opt mood in so the present-case asserts the full surface.
|
||||
sections: { mood: true },
|
||||
moduleMap: { ...ALL_ON },
|
||||
});
|
||||
|
||||
expect(Object.keys(data.glucoseStats).length).toBeGreaterThan(0);
|
||||
expect(data.measurements.ACTIVITY_STEPS).toBeDefined();
|
||||
expect(data.measurements.BLOOD_GLUCOSE).toBeDefined();
|
||||
expect(data.labResults).not.toBeNull();
|
||||
expect(data.mood).not.toBeNull();
|
||||
const scoreTypes = (data.wellnessScores ?? []).map((s) => s.type);
|
||||
expect(scoreTypes).toEqual(
|
||||
expect.arrayContaining([
|
||||
"RECOVERY_SCORE",
|
||||
"STRESS_SCORE",
|
||||
"STRAIN_SCORE",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes a disabled module's section/resources from the payload", async () => {
|
||||
const userId = await seedUserWithEveryDataType("dr-modules-off");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE, {
|
||||
// The user opted mood in at the report level, but turned the modules off
|
||||
// — the module gate wins, so none of these surface.
|
||||
sections: { mood: true, sleep: true, labs: true },
|
||||
moduleMap: {
|
||||
...ALL_ON,
|
||||
glucose: false,
|
||||
sleep: false,
|
||||
workouts: false,
|
||||
recovery: false,
|
||||
labs: false,
|
||||
mood: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Glucose panel collapses entirely (stats + raw series).
|
||||
expect(data.glucoseStats).toEqual({});
|
||||
expect(data.glucoseRanges).toEqual({});
|
||||
expect(data.measurements.BLOOD_GLUCOSE).toBeUndefined();
|
||||
// Sleep series stripped (sections + module agree).
|
||||
expect(data.measurements.SLEEP_DURATION).toBeUndefined();
|
||||
// Workout activity series stripped.
|
||||
expect(data.measurements.ACTIVITY_STEPS).toBeUndefined();
|
||||
expect(data.stats.ACTIVITY_STEPS).toBeUndefined();
|
||||
// Labs panel gone.
|
||||
expect(data.labResults).toBeNull();
|
||||
// Mood never assembled.
|
||||
expect(data.mood).toBeNull();
|
||||
// Recovery + strain wellness scores dropped (recovery + workouts off).
|
||||
const scoreTypes = (data.wellnessScores ?? []).map((s) => s.type);
|
||||
expect(scoreTypes).not.toContain("RECOVERY_SCORE");
|
||||
expect(scoreTypes).not.toContain("STRESS_SCORE");
|
||||
expect(scoreTypes).not.toContain("STRAIN_SCORE");
|
||||
// Core clinical sections stay unconditional.
|
||||
expect(data.stats.WEIGHT).toBeDefined();
|
||||
expect(data.stats.BLOOD_PRESSURE_SYS).toBeDefined();
|
||||
expect(data.stats.PULSE).toBeDefined();
|
||||
expect(Object.keys(data.compliance).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("gates recovery and workouts independently (recovery off, workouts on)", async () => {
|
||||
const userId = await seedUserWithEveryDataType("dr-modules-recovery-off");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE, {
|
||||
moduleMap: { ...ALL_ON, recovery: false },
|
||||
});
|
||||
|
||||
const scoreTypes = (data.wellnessScores ?? []).map((s) => s.type);
|
||||
// Recovery readiness scores gone…
|
||||
expect(scoreTypes).not.toContain("RECOVERY_SCORE");
|
||||
expect(scoreTypes).not.toContain("STRESS_SCORE");
|
||||
// …but the workout-owned strain score + activity series stay.
|
||||
expect(scoreTypes).toContain("STRAIN_SCORE");
|
||||
expect(data.measurements.ACTIVITY_STEPS).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor-report — MedicationAdministration export cap", () => {
|
||||
it("caps the administration set at the configured ceiling and flags the truncation", async () => {
|
||||
// The default ceiling is 5000; pin the operator-configurable
|
||||
|
||||
Reference in new issue
Block a user