mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
chore(release): v1.28.45 — doctor-report + privacy fixes
Three CONFIRMED findings from the campaign's feature-wiring audit + structural verification: - Sleep rendered nothing in the doctor-report PDF: the sleep section is default-on and the data layer reconstructs per-night SLEEP_DURATION rows, but DOCTOR_REPORT_VITAL_TYPES omitted SLEEP_DURATION, so the vitals table showed nothing (FHIR already emitted it). Added it to the allowlist with a minutes-to-hours conversion; corrected the stale 'intentionally excluded' comment. - Glucose had no doctor-report section toggle — it rendered whenever the global glucose module was on, with no report-level privacy control. The toggle actually already existed in the UI (with i18n) but was dead because the prefs fold dropped the key. Wired it end to end (glucose added to the prefs Zod schema + defaults, default-on to preserve behaviour; gated the render loop + the query). Also fixed a grouped-vs-flat prefs mis-detection: 'glucose' in GROUPED_SECTION_KEYS would have flagged flat blobs (incl. EMPTY prefs) as grouped and defaulted them all on. No migration (prefs is a JSON blob). - environment fed the coach with no exclusion: MODULE_EXCLUDED_SOURCES omitted it, so module-off still leaked audio_env/audio_headphone/audio_event/daylight/ skin_temp to the snapshot. Added, scoped exactly to the environment cluster's five sources. Regression test per fix.
This commit is contained in:
1 parent
15a09d77a0
commit
0796d9fe14
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