From 704ef11eab711903c4c4d2416f4a1aaf25dccd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 22:21:05 +0200 Subject: [PATCH 01/50] test: pin the km-scale walking distance the export import stores as metres The Apple export writes DistanceWalkingRunning in the account's own length unit; the record mapping assumes metres and never reads the attribute, so a 2.484 km day lands as 2.484 m. Refs #944 --- .../import-apple-health-export.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/lib/measurements/__tests__/import-apple-health-export.test.ts b/src/lib/measurements/__tests__/import-apple-health-export.test.ts index ddb4c3c6c..c63637ec9 100644 --- a/src/lib/measurements/__tests__/import-apple-health-export.test.ts +++ b/src/lib/measurements/__tests__/import-apple-health-export.test.ts @@ -1278,3 +1278,35 @@ describe("streamParseExportXml — the archive's own export stamp", () => { expect(stamps).toEqual([]); }); }); + +describe("streamParseExportXml — the record's own unit (issue #944)", () => { + it("stores a km-scale walking distance in metres", async () => { + const { prisma } = await importCumulativeFixture( + cumulativeExportXml( + "HKQuantityTypeIdentifierDistanceWalkingRunning", + "km", + [{ value: 2.484, sourceName: "iPhone", hour: 8 }], + ), + ); + + expect(prisma._measurements).toHaveLength(1); + expect(prisma._measurements[0]).toMatchObject({ + type: "WALKING_RUNNING_DISTANCE", + unit: "m", + }); + expect(prisma._measurements[0].value).toBeCloseTo(2484, 6); + }); + + it("stores a mile-scale walking distance in metres", async () => { + const { prisma } = await importCumulativeFixture( + cumulativeExportXml( + "HKQuantityTypeIdentifierDistanceWalkingRunning", + "mi", + [{ value: 1.543, sourceName: "iPhone", hour: 8 }], + ), + ); + + expect(prisma._measurements).toHaveLength(1); + expect(prisma._measurements[0].value).toBeCloseTo(2483.217792, 6); + }); +}); From 5c7c7afd591b3ef747c254767950d76fcfba5356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 22:31:31 +0200 Subject: [PATCH 02/50] fix(import): honour the unit an Apple Health record carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple stamps every quantity `` of an export archive with the account's own display unit, not the unit the mapping table assumes: a metric archive reports walking distance in km, an imperial one in mi, body mass in lb, energy in kJ, temperature in degF. The mapper accepted `input.unit` and never read it, so a 2.484 km day was folded into the day's total as 2.484 m and a decade of imported history read as a couple of metres walked. `hk-units.ts` is now the one place a HealthKit unit is converted, shared by the record path and the workout path (which carried its own km/mi branch — the duplicate is what let the record path stay wrong). It converts only inside a family and only for units Apple can emit, and returns the reading untouched for anything it cannot place, so an archive that already speaks the table's unit is unchanged. Every mapping-table entry whose unit cannot be converted — a dimensionless count, a pinned event, a logarithmic dB level, a compound rate, the percent units whose value rides as a 0..1 fraction — now carries a written reason, and a structural guard fails on an entry that offers neither a convertible unit nor a reason. Refs #944 --- .../apple-health-unit-audit-guard.test.ts | 165 ++++++++++++++++ .../measurements/__tests__/hk-units.test.ts | 77 ++++++++ src/lib/measurements/apple-health-mapping.ts | 86 +++++++- src/lib/measurements/hk-units.ts | 185 ++++++++++++++++++ .../import-apple-health-export.ts | 13 +- 5 files changed, 515 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/apple-health-unit-audit-guard.test.ts create mode 100644 src/lib/measurements/__tests__/hk-units.test.ts create mode 100644 src/lib/measurements/hk-units.ts diff --git a/src/__tests__/apple-health-unit-audit-guard.test.ts b/src/__tests__/apple-health-unit-audit-guard.test.ts new file mode 100644 index 000000000..49e162269 --- /dev/null +++ b/src/__tests__/apple-health-unit-audit-guard.test.ts @@ -0,0 +1,165 @@ +/** + * Structural guard on the Apple Health mapping table's unit column + * (issue #944). + * + * `AppleHealthMapping.hkUnit` records the unit HealthLog ASSUMES a + * HealthKit reading arrives in, and `convertToDbUnit` is written against + * that assumption. Apple's `export.xml` does not honour it: every quantity + * `` carries the account's own display unit, so a metric archive + * reports walking distance in `km` and an imperial one in `mi`. For ten + * years those readings were stored as if the number were already metres — + * the whole history understated by a factor of a thousand — because the + * mapper accepted `input.unit` and never read it. + * + * So an entry now has exactly two legal shapes: + * + * 1. its `hkUnit` is one `convertHkValue()` knows a factor for, in which + * case `mapAppleHealthEntry()` converts a differing record unit into + * it before `convertToDbUnit` runs; or + * 2. it carries a written `unitFixedReason` saying why no unit attribute + * Apple could write would change the reading (a dimensionless count, + * a pinned event, a logarithmic dB level, a compound rate). + * + * The reason arm alone would be a guard that cannot fail: an entry could + * declare a convertible unit while the mapper quietly ignored it. The + * behavioural block below therefore feeds every convertible entry a + * reading in a SIBLING unit and asserts the stored number moved by the + * documented factor — so deleting the conversion turns this guard red, + * not just the one reproduction test. + * + * A tripwire, not a proof: a reviewer who waves through a wrong factor + * defeats it. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + APPLE_HEALTH_TYPE_MAP, + mapAppleHealthEntry, +} from "@/lib/measurements/apple-health-mapping"; +import { + convertHkValue, + isConvertibleHkUnit, +} from "@/lib/measurements/hk-units"; + +const ENTRIES = Object.values(APPLE_HEALTH_TYPE_MAP); + +/** + * Units from every family the table touches. The behavioural block picks + * the first one that converts into an entry's `hkUnit` and actually moves + * the number, so a new family only needs one representative here. + */ +const SIBLING_UNITS = [ + "km", + "mi", + "cm", + "lb", + "st", + "kJ", + "cal", + "degF", + "K", + "mmol<180.156>/L", + "kPa", + "km/hr", + "hr", + "ms", +]; + +function siblingUnitFor(hkUnit: string): string | null { + for (const candidate of SIBLING_UNITS) { + const converted = convertHkValue(1, candidate, hkUnit); + if (converted !== null && converted !== 1) return candidate; + } + return null; +} + +describe("Apple Health mapping — the record's own unit (issue #944)", () => { + it("examines the whole mapping table", () => { + // An empty match set is the failure mode this repo keeps rediscovering: + // a guard is green because it matched nothing, not because nothing is + // wrong. Pin the floor. + expect(ENTRIES.length).toBeGreaterThan(40); + }); + + it.each(ENTRIES)( + "$hkIdentifier either converts its record unit or says why it cannot", + (mapping) => { + if (isConvertibleHkUnit(mapping.hkUnit)) { + expect(mapping.unitFixedReason).toBeUndefined(); + return; + } + expect( + mapping.unitFixedReason, + `${mapping.hkIdentifier} pins hkUnit "${mapping.hkUnit}" with no conversion and no written reason`, + ).toBeTypeOf("string"); + // Long enough to be a sentence rather than a shrug. + expect(mapping.unitFixedReason!.length).toBeGreaterThan(40); + }, + ); + + const convertible = ENTRIES.filter((mapping) => + isConvertibleHkUnit(mapping.hkUnit), + ); + + it("has convertible entries to exercise", () => { + expect(convertible.length).toBeGreaterThan(10); + }); + + it.each(convertible)( + "$hkIdentifier honours a sibling unit on the record itself", + (mapping) => { + const sibling = siblingUnitFor(mapping.hkUnit); + expect( + sibling, + `no sibling unit listed for the family of "${mapping.hkUnit}"`, + ).not.toBeNull(); + + const base = { + hkIdentifier: mapping.hkIdentifier, + value: 2, + startDate: "2026-05-14T08:00:00.000Z", + endDate: "2026-05-14T08:30:00.000Z", + }; + const native = mapAppleHealthEntry({ ...base, unit: mapping.hkUnit }); + const foreign = mapAppleHealthEntry({ ...base, unit: sibling! }); + expect(native).not.toBeNull(); + expect(foreign).not.toBeNull(); + + const expected = mapping.convertToDbUnit( + convertHkValue(2, sibling!, mapping.hkUnit)!, + ); + expect(foreign!.value).toBeCloseTo(expected, 9); + expect(foreign!.value).not.toBeCloseTo(native!.value, 9); + }, + ); + + it("keeps a unit it cannot place from rescaling the reading", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierDistanceWalkingRunning", + value: 1234, + unit: "furlong", + startDate: "2026-05-14T08:00:00.000Z", + endDate: "2026-05-14T08:30:00.000Z", + }); + expect(out?.value).toBe(1234); + }); + + it("leaves the km/mi factors in one shared module", () => { + // The workout path used to carry its own copy of the km/mi branch; the + // duplicate is what let the `` path stay wrong for ten years. + const shared = join(process.cwd(), "src/lib/measurements/hk-units.ts"); + const owners = [ + "src/lib/measurements/apple-health-mapping.ts", + "src/lib/measurements/import-apple-health-export.ts", + ]; + expect(readFileSync(shared, "utf8")).toContain("1609.344"); + for (const rel of owners) { + const source = readFileSync(join(process.cwd(), rel), "utf8"); + expect(source, `${rel} re-implements a length factor`).not.toMatch( + /1609\.344|\* 1000\b/, + ); + } + }); +}); diff --git a/src/lib/measurements/__tests__/hk-units.test.ts b/src/lib/measurements/__tests__/hk-units.test.ts new file mode 100644 index 000000000..b1162fb6b --- /dev/null +++ b/src/lib/measurements/__tests__/hk-units.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; + +import { + convertHkValue, + hkDistanceToMetres, + isConvertibleHkUnit, +} from "../hk-units"; + +describe("convertHkValue", () => { + it("converts the km reading Apple writes on a metric archive", () => { + expect(convertHkValue(2.484, "km", "m")).toBeCloseTo(2484, 9); + }); + + it("converts the mi reading Apple writes on an imperial archive", () => { + expect(convertHkValue(1.543, "mi", "m")).toBeCloseTo(2483.217792, 9); + }); + + it("returns the reading untouched when the units already agree", () => { + expect(convertHkValue(2.484, "m", "m")).toBe(2.484); + }); + + it("converts mass, energy and temperature the same way", () => { + expect(convertHkValue(180, "lb", "kg")).toBeCloseTo(81.6466266, 6); + expect(convertHkValue(1000, "kJ", "kcal")).toBeCloseTo(239.005736, 6); + expect(convertHkValue(98.6, "degF", "degC")).toBeCloseTo(37, 9); + }); + + it("strips the molar mass Apple annotates a glucose unit with", () => { + expect(convertHkValue(5.5, "mmol<180.156>/L", "mg/dL")).toBeCloseTo( + 99.0858, + 4, + ); + }); + + it("keeps the small calorie apart from the large one", () => { + expect(convertHkValue(1, "Cal", "kcal")).toBe(1); + expect(convertHkValue(1000, "cal", "kcal")).toBeCloseTo(1, 9); + }); + + it("refuses a unit it cannot place rather than guessing", () => { + expect(convertHkValue(1, "furlong", "m")).toBeNull(); + expect(convertHkValue(1, "", "m")).toBeNull(); + expect(convertHkValue(1, undefined, "m")).toBeNull(); + }); + + it("refuses to cross families", () => { + expect(convertHkValue(1, "kg", "m")).toBeNull(); + expect(convertHkValue(1, "min", "kcal")).toBeNull(); + }); + + it("refuses a non-finite reading", () => { + expect(convertHkValue(Number.NaN, "km", "m")).toBeNull(); + }); +}); + +describe("hkDistanceToMetres", () => { + it("converts the workout distance units Apple ships", () => { + expect(hkDistanceToMetres(6.5, "km")).toBeCloseTo(6500, 9); + expect(hkDistanceToMetres(3, "mi")).toBeCloseTo(4828.032, 9); + expect(hkDistanceToMetres(500, "m")).toBe(500); + }); + + it("falls back to the raw number for a missing unit attribute", () => { + expect(hkDistanceToMetres(500, "")).toBe(500); + expect(hkDistanceToMetres(500, undefined)).toBe(500); + }); +}); + +describe("isConvertibleHkUnit", () => { + it("recognises the units the mapping table pins", () => { + expect(isConvertibleHkUnit("m")).toBe(true); + expect(isConvertibleHkUnit("kg")).toBe(true); + expect(isConvertibleHkUnit("count")).toBe(false); + expect(isConvertibleHkUnit("event")).toBe(false); + expect(isConvertibleHkUnit("%")).toBe(false); + }); +}); diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index e665b18bd..4e760acc0 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -28,6 +28,7 @@ import type { SleepStage, RhythmClassification, } from "@/generated/prisma/client"; +import { convertHkValue } from "./hk-units"; /** * How a batch of HealthKit samples for the same identifier should be @@ -47,6 +48,21 @@ export interface AppleHealthMapping { measurementType: MeasurementType; /** Apple's HK unit string (e.g. `kg`, `count/min`, `mL/(kg*min)`). */ hkUnit: string; + /** + * issue #944 — why this entry's `hkUnit` can be trusted as fixed. + * + * Apple's `export.xml` writes the ACCOUNT's display unit on every + * quantity ``, not the unit this table assumes, so + * `mapAppleHealthEntry()` converts the reading from the record's own + * unit into `hkUnit` before `convertToDbUnit` runs. That only works for + * a unit `convertHkValue()` knows a factor for. An entry whose `hkUnit` + * is NOT convertible (a dimensionless count, a pinned event, a + * logarithmic dB level, a compound rate) must say here why no record + * unit can change its reading — the structural guard + * `src/__tests__/apple-health-unit-audit-guard.test.ts` fails on an + * entry that offers neither. + */ + unitFixedReason?: string; /** HealthLog canonical DB unit (must match `getUnitForType()`). */ dbUnit: string; /** Convert a HK sample value into the canonical DB unit. Identity for most. */ @@ -189,6 +205,28 @@ export const APPLE_HEALTH_SLEEP_STAGE_MAP: Record = { * metric: check Apple's HK unit; percent → ×100 path, m/m·s/kg/etc * → identity path. */ +/** + * issue #944 — the written reasons a mapping entry may cite for keeping a + * fixed `hkUnit`. Each one answers the same question: what could Apple + * possibly write in this record's `unit` attribute that would change the + * reading? Shared constants rather than free text so the audit reads as + * one decision per class instead of forty near-duplicates. + */ +const UNIT_FIXED_DIMENSIONLESS_COUNT = + "Dimensionless tally: Apple emits `count` in every locale and HKUnit has no sibling spelling for it, so no record unit can rescale the reading."; +const UNIT_FIXED_PER_MINUTE = + "Per-minute rate: Apple emits `count/min` in every locale and the Health app offers no alternative display unit, so there is nothing to convert from."; +const UNIT_FIXED_PERCENT_FRACTION = + "Apple writes the percent unit but the VALUE rides as a 0..1 fraction; the x100 in `convertToDbUnit` already covers both spellings, so branching on the record unit would double-scale it."; +const UNIT_FIXED_EVENT_PIN = + "Categorical event: `convertToDbUnit` pins the stored value to 1 fired event regardless of the reading, so no unit attribute can move it."; +const UNIT_FIXED_AUDIO_SPL = + "Sound-pressure level: Apple emits `dBASPL` only, and a decibel is logarithmic — a sibling unit would need its own formula, never a factor."; +const UNIT_FIXED_COMPOSITE_VO2 = + "Compound rate Apple emits verbatim as `mL/(kg*min)`; the Health app exposes no other spelling and the compound has no sibling unit to convert from."; +const UNIT_FIXED_SLEEP_CATEGORY = + "Sleep samples carry a symbolic category value, not a quantity: the importer derives the duration in minutes from the sample's own dates, so the record's unit attribute (empty in Apple's archive) says nothing about the reading."; + export const APPLE_HEALTH_TYPE_MAP: Record = { // ── Body composition ──────────────────────────────────────── HKQuantityTypeIdentifierBodyMass: { @@ -203,6 +241,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierBodyFatPercentage", measurementType: "BODY_FAT", hkUnit: "%", + unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", // Apple ships 0..1 fraction; HealthLog stores 0..100. convertToDbUnit: (v) => v * 100, @@ -254,6 +293,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierHeartRate", measurementType: "PULSE", hkUnit: "count/min", + unitFixedReason: UNIT_FIXED_PER_MINUTE, dbUnit: "bpm", convertToDbUnit: (v) => v, aggregation: "latest", @@ -262,6 +302,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierRestingHeartRate", measurementType: "RESTING_HEART_RATE", hkUnit: "count/min", + unitFixedReason: UNIT_FIXED_PER_MINUTE, dbUnit: "bpm", convertToDbUnit: (v) => v, aggregation: "latest", @@ -284,6 +325,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { // duplication; see `.planning/phase-W1-v1423-research.md`). measurementType: "ACTIVITY_STEPS", hkUnit: "count", + unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT, dbUnit: "steps", convertToDbUnit: (v) => v, aggregation: "sum", @@ -300,6 +342,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierFlightsClimbed", measurementType: "FLIGHTS_CLIMBED", hkUnit: "count", + unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT, dbUnit: "flights", convertToDbUnit: (v) => v, aggregation: "sum", @@ -318,6 +361,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierVO2Max", measurementType: "VO2_MAX", hkUnit: "mL/(kg*min)", + unitFixedReason: UNIT_FIXED_COMPOSITE_VO2, dbUnit: "mL/(kg·min)", convertToDbUnit: (v) => v, aggregation: "latest", @@ -337,6 +381,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierOxygenSaturation", measurementType: "OXYGEN_SATURATION", hkUnit: "fraction", + unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", // Apple ships 0..1 fraction; HealthLog stores 0..100. convertToDbUnit: (v) => v * 100, @@ -348,6 +393,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", measurementType: "SLEEP_DURATION", hkUnit: "category", + unitFixedReason: UNIT_FIXED_SLEEP_CATEGORY, dbUnit: "minutes", // value here is duration-in-minutes (caller computes // `endDate - startDate` and converts to minutes); the per-stage @@ -367,6 +413,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierEnvironmentalAudioExposure", measurementType: "AUDIO_EXPOSURE_ENV", hkUnit: "dBASPL", + unitFixedReason: UNIT_FIXED_AUDIO_SPL, dbUnit: "dBA", convertToDbUnit: (v) => v, aggregation: "mean", @@ -379,6 +426,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierHeadphoneAudioExposure", measurementType: "AUDIO_EXPOSURE_HEADPHONE", hkUnit: "dBASPL", + unitFixedReason: UNIT_FIXED_AUDIO_SPL, dbUnit: "dBA", convertToDbUnit: (v) => v, aggregation: "mean", @@ -403,6 +451,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierAppleWalkingSteadiness", measurementType: "WALKING_STEADINESS", hkUnit: "%", + unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", // Apple ships 0..1 fraction; HealthLog stores 0..100. convertToDbUnit: (v) => v * 100, @@ -416,6 +465,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent", measurementType: "AUDIO_EXPOSURE_EVENT", hkUnit: "count", + unitFixedReason: UNIT_FIXED_EVENT_PIN, dbUnit: "count", convertToDbUnit: () => 1, aggregation: "sum", @@ -427,6 +477,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent", measurementType: "AUDIO_EXPOSURE_EVENT", hkUnit: "count", + unitFixedReason: UNIT_FIXED_EVENT_PIN, dbUnit: "count", convertToDbUnit: () => 1, aggregation: "sum", @@ -449,6 +500,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", measurementType: "IRREGULAR_RHYTHM_NOTIFICATION", hkUnit: "event", + unitFixedReason: UNIT_FIXED_EVENT_PIN, dbUnit: "event", convertToDbUnit: () => 1, aggregation: "sum", @@ -462,6 +514,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierHighHeartRateEvent", measurementType: "HIGH_HEART_RATE_EVENT", hkUnit: "event", + unitFixedReason: UNIT_FIXED_EVENT_PIN, dbUnit: "event", convertToDbUnit: () => 1, aggregation: "sum", @@ -474,6 +527,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierLowHeartRateEvent", measurementType: "LOW_HEART_RATE_EVENT", hkUnit: "event", + unitFixedReason: UNIT_FIXED_EVENT_PIN, dbUnit: "event", convertToDbUnit: () => 1, aggregation: "sum", @@ -487,6 +541,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", measurementType: "WALKING_STEADINESS_EVENT", hkUnit: "event", + unitFixedReason: UNIT_FIXED_EVENT_PIN, dbUnit: "event", convertToDbUnit: () => 1, aggregation: "sum", @@ -501,6 +556,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierSleepApneaEvent", measurementType: "BREATHING_DISTURBANCE_EVENT", hkUnit: "event", + unitFixedReason: UNIT_FIXED_EVENT_PIN, dbUnit: "event", convertToDbUnit: () => 1, aggregation: "sum", @@ -524,6 +580,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierRespiratoryRate", measurementType: "RESPIRATORY_RATE", hkUnit: "count/min", + unitFixedReason: UNIT_FIXED_PER_MINUTE, dbUnit: "breaths/min", convertToDbUnit: (v) => v, aggregation: "mean", @@ -537,6 +594,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierBodyMassIndex", measurementType: "BODY_MASS_INDEX", hkUnit: "count", + unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT, dbUnit: "kg/m²", convertToDbUnit: (v) => v, aggregation: "latest", @@ -557,6 +615,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierWalkingHeartRateAverage", measurementType: "WALKING_HEART_RATE_AVERAGE", hkUnit: "count/min", + unitFixedReason: UNIT_FIXED_PER_MINUTE, dbUnit: "bpm", convertToDbUnit: (v) => v, aggregation: "mean", @@ -570,6 +629,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierWalkingAsymmetryPercentage", measurementType: "WALKING_ASYMMETRY", hkUnit: "%", + unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", convertToDbUnit: (v) => v * 100, aggregation: "latest", @@ -580,6 +640,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", measurementType: "WALKING_DOUBLE_SUPPORT", hkUnit: "%", + unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", convertToDbUnit: (v) => v * 100, aggregation: "latest", @@ -622,6 +683,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", measurementType: "CARDIO_RECOVERY", hkUnit: "count/min", + unitFixedReason: UNIT_FIXED_PER_MINUTE, dbUnit: "bpm", convertToDbUnit: (v) => v, aggregation: "latest", @@ -645,6 +707,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierNumberOfTimesFallen", measurementType: "FALL_COUNT", hkUnit: "count", + unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT, dbUnit: "count", convertToDbUnit: (v) => v, aggregation: "sum", @@ -685,6 +748,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", measurementType: "BREATHING_DISTURBANCES", hkUnit: "count", + unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT, dbUnit: "count", convertToDbUnit: (v) => v, aggregation: "latest", @@ -1140,7 +1204,12 @@ export interface AppleHealthEntryInput { hkIdentifier: string; /** Numeric value as Apple delivers it (pre-conversion). */ value: number; - /** Apple's unit string, captured for audit; not currently validated. */ + /** + * Apple's unit string for this reading. issue #944 — the mapper converts + * the value from this unit into the table's `hkUnit` when the two differ + * and a factor is known (`km` -> `m`, `lb` -> `kg`, `degF` -> `degC`, …); + * an unknown or unplaceable unit leaves the reading untouched. + */ unit: string; /** ISO timestamp string (e.g. HealthKit `startDate`). */ startDate: string; @@ -1221,7 +1290,16 @@ export function mapAppleHealthEntry( const takenAt = new Date(input.endDate); if (Number.isNaN(takenAt.getTime())) return null; - const value = mapping.convertToDbUnit(input.value); + // issue #944 — `convertToDbUnit` is written against the table's fixed + // `hkUnit`, but Apple's `export.xml` stamps each `` with the + // ACCOUNT's own display unit: a metric archive reports walking distance + // in `km`, an imperial one in `mi`, body mass in `lb`, energy in `kJ`. + // Normalise the reading into `hkUnit` first; a unit we cannot place + // leaves the value untouched (see `convertHkValue`). + const toHkUnit = (raw: number): number => + convertHkValue(raw, input.unit, mapping.hkUnit) ?? raw; + + const value = mapping.convertToDbUnit(toHkUnit(input.value)); const out: AppleHealthEntryOutput = { type: mapping.measurementType, @@ -1235,10 +1313,10 @@ export function mapAppleHealthEntry( // hourly HR bucket). A finite check guards a malformed payload; the // caller only persists these on the HR-bucket row. if (typeof input.valueMin === "number" && Number.isFinite(input.valueMin)) { - out.valueMin = mapping.convertToDbUnit(input.valueMin); + out.valueMin = mapping.convertToDbUnit(toHkUnit(input.valueMin)); } if (typeof input.valueMax === "number" && Number.isFinite(input.valueMax)) { - out.valueMax = mapping.convertToDbUnit(input.valueMax); + out.valueMax = mapping.convertToDbUnit(toHkUnit(input.valueMax)); } if (mapping.sleepStageMap) { diff --git a/src/lib/measurements/hk-units.ts b/src/lib/measurements/hk-units.ts new file mode 100644 index 000000000..f500be22d --- /dev/null +++ b/src/lib/measurements/hk-units.ts @@ -0,0 +1,185 @@ +/** + * HealthKit unit normalisation (issue #944). + * + * Apple writes a `unit` attribute on every quantity `` of an + * `export.xml` archive, and the string it writes is the account's OWN + * display unit rather than the unit HealthLog's mapping table assumes: + * a metric account exports walking distance as `km`, an imperial one as + * `mi`, and the same split shows up on body mass (`kg` / `lb` / `st`), + * energy (`kcal` / `kJ`) and temperature (`degC` / `degF`). + * + * Before this module the export import fed the raw number straight into + * `AppleHealthMapping.convertToDbUnit`, which is written against the + * table's fixed `hkUnit` — so a 2.484 km day was stored as 2.484 m. The + * workout path had a hand-rolled km/mi→m branch of its own; both paths + * now share `convertHkValue()`. + * + * Contract, deliberately conservative: + * + * - Same unit in and out → the value is returned untouched, so an + * archive (or an iOS batch) that already speaks the table's unit is + * byte-for-byte unchanged. + * - Unknown unit, empty unit, or two units from different families → + * `null`, and every caller falls back to the raw value. A unit we + * cannot prove a factor for must never silently rescale a reading. + * - Only units Apple's `HKUnit` can actually emit are listed. A unit + * string is matched exactly (after trimming and after stripping the + * molar-mass annotation Apple writes on glucose), never lower-cased: + * `cal` (small calorie) and `Cal` (kilocalorie) are different units + * that differ only in case. + */ + +interface HkUnitSpec { + /** Units convert only within one family. */ + family: string; + /** This unit → the family's base unit. */ + toBase: (value: number) => number; + /** The family's base unit → this unit. */ + fromBase: (value: number) => number; +} + +/** A unit that is a plain multiple of its family's base unit. */ +function scaled(family: string, perBase: number): HkUnitSpec { + return { + family, + toBase: (value) => value * perBase, + fromBase: (value) => value / perBase, + }; +} + +/** + * Every unit string Apple emits that HealthLog can convert, keyed by the + * exact `HKUnit.unitString` spelling. Base units: metre, kilogram, + * kilocalorie, second, degree Celsius, mmHg, mg/dL, metres per second. + */ +const HK_UNITS: Record = { + // ── Length (base: metre) ─────────────────────────────────── + m: scaled("length", 1), + km: scaled("length", 1000), + cm: scaled("length", 0.01), + mm: scaled("length", 0.001), + in: scaled("length", 0.0254), + ft: scaled("length", 0.3048), + yd: scaled("length", 0.9144), + mi: scaled("length", 1609.344), + + // ── Mass (base: kilogram) ────────────────────────────────── + kg: scaled("mass", 1), + g: scaled("mass", 0.001), + lb: scaled("mass", 0.45359237), + oz: scaled("mass", 0.028349523125), + st: scaled("mass", 6.35029318), + + // ── Energy (base: kilocalorie) ───────────────────────────── + kcal: scaled("energy", 1), + Cal: scaled("energy", 1), // Apple's large calorie == kilocalorie + cal: scaled("energy", 0.001), // small calorie — case is load-bearing + kJ: scaled("energy", 1 / 4.184), + J: scaled("energy", 1 / 4184), + + // ── Time (base: second) ──────────────────────────────────── + s: scaled("time", 1), + ms: scaled("time", 0.001), + min: scaled("time", 60), + hr: scaled("time", 3600), + d: scaled("time", 86_400), + + // ── Pressure (base: mmHg) ────────────────────────────────── + mmHg: scaled("pressure", 1), + kPa: scaled("pressure", 7.500_616_827_041_698), + Pa: scaled("pressure", 0.007_500_616_827_041_698), + + // ── Blood glucose (base: mg/dL) ──────────────────────────── + // Apple annotates the molar unit with glucose's molar mass, which the + // normaliser strips before the lookup. + "mg/dL": scaled("glucose", 1), + "mmol/L": scaled("glucose", 18.0156), // 180.156 g/mol ÷ 10 + + // ── Speed (base: metres per second) ──────────────────────── + "m/s": scaled("speed", 1), + "km/hr": scaled("speed", 1000 / 3600), + "mi/hr": scaled("speed", 1609.344 / 3600), + + // ── Temperature (base: degree Celsius) — affine, not scaled ─ + degC: { + family: "temperature", + toBase: (value) => value, + fromBase: (value) => value, + }, + degF: { + family: "temperature", + toBase: (value) => ((value - 32) * 5) / 9, + fromBase: (value) => (value * 9) / 5 + 32, + }, + K: { + family: "temperature", + toBase: (value) => value - 273.15, + fromBase: (value) => value + 273.15, + }, +}; + +/** + * Apple writes molar units with the substance's molar mass inline — + * `mmol<180.156>/L` for blood glucose. The annotation is informational; + * strip it so the lookup sees the plain unit. + */ +const MOLAR_MASS_ANNOTATION = /<[^>]*>/g; + +function normaliseUnitString(unit: string): string { + return unit.trim().replace(MOLAR_MASS_ANNOTATION, ""); +} + +function lookup(unit: string | null | undefined): HkUnitSpec | null { + if (typeof unit !== "string") return null; + const normalised = normaliseUnitString(unit); + if (!normalised) return null; + return HK_UNITS[normalised] ?? null; +} + +/** + * `true` when this module knows a conversion factor for the unit — i.e. + * a `` that carries a sibling unit from the same family will be + * converted rather than trusted blindly. The mapping-table guard in + * `src/__tests__/apple-health-unit-audit-guard.test.ts` uses this to + * decide whether an entry's fixed `hkUnit` needs a written reason. + */ +export function isConvertibleHkUnit(unit: string | null | undefined): boolean { + return lookup(unit) !== null; +} + +/** + * Convert a HealthKit value between two `HKUnit` strings. Returns `null` + * — never a guess — when either unit is unknown or the two belong to + * different families; callers keep the raw value in that case. + */ +export function convertHkValue( + value: number, + fromUnit: string | null | undefined, + toUnit: string, +): number | null { + if (!Number.isFinite(value)) return null; + const from = lookup(fromUnit); + if (!from) return null; + const to = lookup(toUnit); + if (!to) return null; + if (from.family !== to.family) return null; + // Same unit: return the reading untouched rather than round-tripping it + // through the base and picking up a float error. + if (normaliseUnitString(String(fromUnit)) === normaliseUnitString(toUnit)) { + return value; + } + return to.fromBase(from.toBase(value)); +} + +/** + * Convert a distance a HealthKit archive reports in its own unit into + * metres, the canonical DB unit. Falls back to the raw number for a unit + * this module cannot place (including the empty attribute an archive may + * write) — the pre-#944 behaviour of the workout path, preserved. + */ +export function hkDistanceToMetres( + value: number, + unit: string | null | undefined, +): number { + return convertHkValue(value, unit, "m") ?? value; +} diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index 26a1ce0bb..4a4bb9229 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -42,6 +42,7 @@ import { } from "@/lib/measurements/drain-per-sample-cumulative"; import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement"; import { resolveHkWorkoutSportType } from "@/lib/measurements/hk-workout-activity-type-map"; +import { hkDistanceToMetres } from "@/lib/measurements/hk-units"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; @@ -947,14 +948,12 @@ export async function streamParseExportXml( const totalDistance = Number.parseFloat(attrs.totalDistance ?? ""); const totalEnergy = Number.parseFloat(attrs.totalEnergyBurned ?? ""); const distanceUnit = attrs.totalDistanceUnit ?? ""; - // Apple ships km for HKWorkout.totalDistance by default; metres - // is the canonical DB unit. Convert when the unit is km. + // Apple ships the ACCOUNT's own length unit on HKWorkout.totalDistance + // (km for a metric account, mi for an imperial one); metres is the + // canonical DB unit. issue #944 — the same conversion the `` + // path now runs, so the two can never drift apart again. const distanceM = Number.isFinite(totalDistance) - ? distanceUnit === "km" - ? totalDistance * 1000 - : distanceUnit === "mi" - ? totalDistance * 1609.344 - : totalDistance + ? hkDistanceToMetres(totalDistance, distanceUnit) : null; const externalId = hashSampleKey( From 59108e09fac4c24a7326e503bb19d83f3cd9a0ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 22:39:39 +0200 Subject: [PATCH 03/50] fix(test): stop the score suites from talking to a real database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three score engines' unit tests hand `persist*Score` a fake Prisma, but `upsertScoreRow` routes the written row through `afterMeasurementMutation`, which reads the `@/lib/db` singleton instead of the injected client. Both legs of that tail therefore opened a socket to a Postgres the unit job does not run: on CI the attempt failed with ECONNREFUSED a few hundred milliseconds later and logged on the way out, after the test that started it had ended. A console write that lands while the worker is closing its RPC channel fails the whole run with `EnvironmentTeardownError: Closing rpc while "onUserConsoleLog" was pending` — four of nine unit-suite failures in thirty days, every one of them with all tests green, blamed on whichever score file the worker happened to be tearing down. Run 31961548185 carries the full stack: recomputeBucketsForMeasurement <- afterMeasurementMutation <- upsertScoreRow <- persistStressScore <- stress-score.test.ts:105. It stayed invisible on a maintainer's machine because a local Postgres answers on 5432: the same call succeeded quietly, and the suite was writing rollup rows for its fixture users into a real database. The four files that drive `upsertScoreRow` stub the tail. That removes the doomed I/O rather than the log line, and the call they were making by accident is now asserted in score-row.test.ts instead: the v1.37.19 wiring is pinned by an expectation rather than by a stack trace. Verified by mutation — dropping the `afterMeasurementMutation` call from score-row.ts fails the new assertion. The settle guard's sweep is widened while here. Its eight-line handler window and five-line registration preamble were both measured against the sites that existed when it was written and both cut through real code: the console write in mood-rollups.ts sits nine lines into its handler, so the one genuinely unregistered detached promise in the tree was never seen, and the registration in fire-and-forget.ts sits below its handler, so a correct site read as a violation. Registration is now looked for wherever it wraps the statement, by paren depth rather than by line count, and mood-rollups.ts registers its WEEK/MONTH/YEAR enqueue. Verified by mutation: unwrapping that call fails the guard. Gate: the four files, twenty consecutive runs, 20/20 green (53 tests each). Test time across the three engine files falls from 708 ms to 20 ms — the round-trips that are no longer made. --- .../background-task-settle-guard.test.ts | 62 +++++++++++++++++-- .../insights/__tests__/recovery-score.test.ts | 12 ++++ src/lib/insights/__tests__/score-row.test.ts | 46 ++++++++++++++ .../insights/__tests__/strain-score.test.ts | 12 ++++ .../insights/__tests__/stress-score.test.ts | 12 ++++ src/lib/rollups/mood-rollups.ts | 42 +++++++------ 6 files changed, 162 insertions(+), 24 deletions(-) diff --git a/src/__tests__/background-task-settle-guard.test.ts b/src/__tests__/background-task-settle-guard.test.ts index 9ce3fa914..322c7dd6f 100644 --- a/src/__tests__/background-task-settle-guard.test.ts +++ b/src/__tests__/background-task-settle-guard.test.ts @@ -19,6 +19,18 @@ * `.catch()` / `.then()` whose handler writes to the console — the shape that * actually produced the failure. A background path that logs some other way, * or that logs from a callee rather than from the handler, is invisible to it. + * + * It was narrower than that until the window below was widened. An + * eight-line handler window and a five-line registration preamble were both + * measured against the sites that existed when the guard was written, and + * both cut through real code: the console write in `mood-rollups.ts` sits + * nine lines into its handler, so the sweep never saw the one detached + * `Promise.all` in the tree that was genuinely unregistered, and the + * registration in `fire-and-forget.ts` sits BELOW its handler rather than + * above it, so a site that is correct read as a violation. The window now + * spans a plausible handler body and registration counts wherever it wraps + * the statement — above the handle or on the line that files the settled + * result. */ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; @@ -57,6 +69,37 @@ function sourceFiles(): string[] { .filter((rel) => !rel.endsWith(".test.ts") && !rel.endsWith(".test.tsx")); } +/** + * How far past the `.catch(` / `.then(` line a handler's console write still + * counts as this handler's. Twenty lines covers every handler body in the + * tree with room to spare; eight did not. + */ +const HANDLER_WINDOW = 20; + +const registers = (text: string) => /trackBackgroundTask\(/.test(text); + +/** + * The source lines that open a call still unclosed at the start of `line` — + * the chain of enclosing calls the handler sits inside, innermost last. + * + * Paren counting is a coarse reading of TypeScript: a `(` inside a string or a + * comment counts like any other. That costs nothing here, because the question + * asked of the result is only whether one of those lines names + * `trackBackgroundTask`, and a stray paren can at worst widen the chain by a + * line that does not. + */ +function enclosingOpenCallLines(source: string, line: number): string[] { + const lines = source.split("\n"); + const open: number[] = []; + for (let i = 0; i < line - 1; i += 1) { + for (const ch of lines[i]) { + if (ch === "(") open.push(i); + else if (ch === ")") open.pop(); + } + } + return open.map((i) => lines[i]); +} + /** Lines starting a `.catch()` / `.then()` whose handler writes to the console. */ function consoleWritingHandlers(): { file: string; line: number }[] { const found: { file: string; line: number }[] = []; @@ -64,7 +107,7 @@ function consoleWritingHandlers(): { file: string; line: number }[] { const lines = readFileSync(join(SRC, rel), "utf8").split("\n"); lines.forEach((line, index) => { if (!/\.(catch|then)\(/.test(line)) return; - const handler = lines.slice(index, index + 8).join("\n"); + const handler = lines.slice(index, index + HANDLER_WINDOW).join("\n"); if (!/console\.(log|warn|error|info|debug|trace)\(/.test(handler)) return; found.push({ file: rel, line: index + 1 }); }); @@ -149,11 +192,18 @@ describe("background-task settle contract", () => { const unregistered = sites.filter(({ file, line }) => { if (UNREGISTERED_BY_DESIGN.has(file)) return false; - const lines = readFileSync(join(SRC, file), "utf8").split("\n"); - // The registration wraps the handle, so it opens on one of the few lines - // above the `.catch(` / `.then(` the sweep matched. - const preamble = lines.slice(Math.max(0, line - 5), line).join("\n"); - return !/trackBackgroundTask\(/.test(preamble); + const source = readFileSync(join(SRC, file), "utf8"); + // Registered either by wrapping the handle — `trackBackgroundTask(` is + // then one of the calls still open where the handler starts, however + // many lines above that is — or by filing the settled handle just after + // the handler closes. Both shapes are in the tree; a fixed window above + // the match saw neither reliably. + if (enclosingOpenCallLines(source, line).some(registers)) return false; + const lines = source.split("\n"); + const trailer = lines + .slice(line, line + HANDLER_WINDOW + 5) + .filter((l) => registers(l)); + return trailer.length === 0; }); expect( diff --git a/src/lib/insights/__tests__/recovery-score.test.ts b/src/lib/insights/__tests__/recovery-score.test.ts index 375edd22d..12237c9aa 100644 --- a/src/lib/insights/__tests__/recovery-score.test.ts +++ b/src/lib/insights/__tests__/recovery-score.test.ts @@ -12,6 +12,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Derived } from "@/lib/insights/derived/types"; import type { ReadinessValue } from "@/lib/insights/derived/readiness"; +/** + * The post-mutation tail reads the real `@/lib/db` client, not the fake this + * file injects, so a stored score opened a socket to a Postgres the unit job + * does not run. The attempt failed a few hundred milliseconds later and logged + * on the way out — after the test that started it had ended — which is how a + * green file tore its worker's RPC channel down mid-write. Stubbed here; + * `score-row.test.ts` asserts the wiring this stands for. + */ +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn(async () => {}), +})); + const computeReadinessMock = vi.fn<(...args: unknown[]) => Promise>>(); diff --git a/src/lib/insights/__tests__/score-row.test.ts b/src/lib/insights/__tests__/score-row.test.ts index 63c4fb4f6..b21f2b2c9 100644 --- a/src/lib/insights/__tests__/score-row.test.ts +++ b/src/lib/insights/__tests__/score-row.test.ts @@ -14,6 +14,27 @@ vi.mock("@/lib/logging/context", () => ({ annotate: (...args: unknown[]) => annotateMock(...args), })); +/** + * The post-mutation tail talks to the real database, and this file does not. + * + * `upsertScoreRow` routes every stored score through + * `afterMeasurementMutation`, which reads the `@/lib/db` singleton rather than + * the client the caller injected. A unit test that hands in a fake Prisma + * therefore still opened a socket to a Postgres the unit job does not run: the + * attempt failed with ECONNREFUSED a few hundred milliseconds later and both + * legs logged on the way out, after the test that started them had ended. A + * console write that lands while the worker is closing its RPC channel fails + * the whole run with `EnvironmentTeardownError: Closing rpc while + * "onUserConsoleLog" was pending` — four of nine unit-suite failures in thirty + * days, every one of them with all tests green. Stubbing the tail removes the + * doomed I/O rather than the log line, and the call it made by accident is + * asserted below instead. + */ +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn(async () => {}), +})); + +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { scoreDayKey, scoreMeasuredAt, @@ -92,6 +113,31 @@ describe("upsertScoreRow", () => { measuredAt: arg.create.measuredAt, }); }); + + it("routes the written row through the shared post-mutation tail", async () => { + // v1.37.19 wiring: a score row is a Measurement like any other, so the + // (type, day) rollup bucket and the cached status assessment converge on + // the tick that wrote it. Pinned here rather than left to a live database + // call that no unit job can serve. + const prisma = { + measurement: { upsert: vi.fn().mockResolvedValue({}) }, + } as unknown as Parameters[0]; + + await upsertScoreRow(prisma, { + userId: "u1", + type: "STRAIN_SCORE", + externalIdPrefix: "strain:", + score: 42, + now: new Date("2026-06-02T03:00:00Z"), + }); + + expect(afterMeasurementMutation).toHaveBeenCalledWith("u1", [ + { + type: "STRAIN_SCORE", + measuredAt: new Date("2026-06-01T12:00:00.000Z"), + }, + ]); + }); }); describe("runScoreBatch", () => { diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts index 1e47ca9f2..6ac225af0 100644 --- a/src/lib/insights/__tests__/strain-score.test.ts +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -10,6 +10,18 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; +/** + * The post-mutation tail reads the real `@/lib/db` client, not the fake this + * file injects, so a stored score opened a socket to a Postgres the unit job + * does not run. The attempt failed a few hundred milliseconds later and logged + * on the way out — after the test that started it had ended — which is how a + * green file tore its worker's RPC channel down mid-write. Stubbed here; + * `score-row.test.ts` asserts the wiring this stands for. + */ +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn(async () => {}), +})); + import { strainDayKey, strainExternalId, diff --git a/src/lib/insights/__tests__/stress-score.test.ts b/src/lib/insights/__tests__/stress-score.test.ts index 1427c4723..b5f5f5391 100644 --- a/src/lib/insights/__tests__/stress-score.test.ts +++ b/src/lib/insights/__tests__/stress-score.test.ts @@ -10,6 +10,18 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; +/** + * The post-mutation tail reads the real `@/lib/db` client, not the fake this + * file injects, so a stored score opened a socket to a Postgres the unit job + * does not run. The attempt failed a few hundred milliseconds later and logged + * on the way out — after the test that started it had ended — which is how a + * green file tore its worker's RPC channel down mid-write. Stubbed here; + * `score-row.test.ts` asserts the wiring this stands for. + */ +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn(async () => {}), +})); + const computeVitalsBaselineMock = vi.fn(); vi.mock("@/lib/insights/derived/baseline", () => ({ computeVitalsBaseline: (...args: unknown[]) => diff --git a/src/lib/rollups/mood-rollups.ts b/src/lib/rollups/mood-rollups.ts index 78596eea9..9f9986bd0 100644 --- a/src/lib/rollups/mood-rollups.ts +++ b/src/lib/rollups/mood-rollups.ts @@ -48,6 +48,7 @@ import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import { startOfUtcDay } from "@/lib/tz/start-of-utc-day"; import type { Prisma, @@ -307,25 +308,30 @@ export async function recomputeMoodBucketsForEntry( // off the pg-boss critical path. Errors stay caught inside // `enqueueMoodRollupRecompute` so the unhandled-rejection logger // captures any pg-boss failure. - void Promise.all( - ASYNC_GRANULARITIES.map((granularity) => { - const { from, to } = bucketSpan(dateLabel, granularity); - return enqueueMoodRollupRecompute({ - userId, - granularity, - from, - to, - }).catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err); - annotate({ - meta: { - mood_rollup_enqueue_failed: true, - mood_rollup_enqueue_error: message, - }, + // Registered so the test suite can await it: the error below is a console + // write on a detached handle, and one that lands while a vitest worker is + // closing its RPC channel fails the whole run. No-op outside test. + trackBackgroundTask( + Promise.all( + ASYNC_GRANULARITIES.map((granularity) => { + const { from, to } = bucketSpan(dateLabel, granularity); + return enqueueMoodRollupRecompute({ + userId, + granularity, + from, + to, + }).catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + annotate({ + meta: { + mood_rollup_enqueue_failed: true, + mood_rollup_enqueue_error: message, + }, + }); + console.error("[mood-rollups] async enqueue failed:", message); }); - console.error("[mood-rollups] async enqueue failed:", message); - }); - }), + }), + ), ); } From d48bbff01dbdd20b69cb95847ee79ff6d70d9eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 22:42:54 +0200 Subject: [PATCH 04/50] ci: take the dependency audit off pull requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm audit --prod` answers a question about the published advisory database, not about the diff. The same lockfile passes on Monday and fails on Tuesday because someone else published, and the branch that turns red changed nothing: seventeen of the forty-one Security & Quality failures in the last thirty days were advisory publications on branches that touched no dependency (GHSA-jqff-g426-hqxp, GHSA-fph4-wmhf-6fwf, GHSA-f65p-4m7j-42xc and GHSA-5jgf-p345-68v8 in one run alone). A gate that fails for a reason the author cannot act on is a gate people re-run instead of read. It keeps running on every push to `main` and once a day on a new schedule trigger, which is the cadence the question actually has. The other three jobs sit out the scheduled run. Nothing about what ships is loosened: the container scan still builds and scans the image on every push to `main`, and the daily Trivy workflow still scans the lockfile with dev dependencies included and the published image. Gate — the trigger set, read back from the parsed workflow: triggers: push, pull_request, schedule quality | if: github.event_name != 'schedule' dependency-audit | if: github.event_name != 'pull_request' secret-scan | if: github.event_name != 'schedule' docker-security | if: github.event_name == 'push' && github.ref == 'refs/heads/main' --- .github/workflows/security.yml | 19 +++++++++++++++++++ .github/workflows/trivy.yml | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 774f04be7..554e4c40e 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -5,6 +5,11 @@ on: branches: [main] pull_request: branches: [main] + schedule: + # Daily, 05:47 UTC. Carries the dependency audit only — every other job + # here answers "is this change safe" and has nothing to say about a tree + # nobody touched. See the note on `dependency-audit`. + - cron: "47 5 * * *" permissions: contents: read @@ -13,6 +18,7 @@ jobs: quality: name: Lint, Typecheck & Test runs-on: ubuntu-latest + if: github.event_name != 'schedule' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -81,9 +87,21 @@ jobs: fi echo "::notice::OpenAPI spec in sync with Zod source schemas." + # Not on pull requests. `pnpm audit` answers a question about the published + # advisory database, not about the diff: the same lockfile passes on Monday + # and fails on Tuesday because someone else published, and the branch that + # goes red changed nothing. Seventeen of the forty-one failures this workflow + # produced in thirty days were exactly that, and a gate that fails for + # reasons the author cannot act on is a gate people learn to re-run rather + # than read. It runs on every push to `main` and once a day on the schedule + # above, which is the cadence the question actually has. What ships is still + # gated: `docker-security` scans the built image on every push to `main`, and + # `trivy.yml` scans the lockfile (dev dependencies included) and the + # published image daily. dependency-audit: name: Dependency Audit runs-on: ubuntu-latest + if: github.event_name != 'pull_request' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -109,6 +127,7 @@ jobs: secret-scan: name: Secret Scanning runs-on: ubuntu-latest + if: github.event_name != 'schedule' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index dcc54336d..0bec8cd01 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -4,7 +4,7 @@ name: Trivy Scheduled Scan # # What already exists and is NOT repeated here: # - `security.yml` → `dependency-audit` runs `pnpm audit --prod` on every -# push and pull request, blocking on HIGH/CRITICAL. +# push to `main` and once a day, blocking on HIGH/CRITICAL. # - `security.yml` → `docker-security` builds the image on a push to `main` # and runs `trivy image` against it, blocking on HIGH/CRITICAL. # From bc7eff83222a18225e54a602a75e600bd920e0a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 22:45:13 +0200 Subject: [PATCH 05/50] feat(ops): repair walking distances stored a thousand times too small MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit fix only helps the next import. An account that imported an archive before it carries a decade of days understated 1000x, and the authoritative repair — re-importing the archive on the fixed build — needs an archive the account may no longer have. `scripts/repair-apple-health-distance.ts` is dry-run by default and selects rows only by the stamp the archive fold itself writes: the EXPORT_XML_SOURCE_MAX provenance plus the `stats:` external id minted for that fold. Never by "the value looks small", so a row a native sync has since overwritten is left alone. `--apply` multiplies one account per transaction and writes the audit row in the same transaction, which is what makes a second run a no-op. The archive's own unit is named by the operator (`--unit=mi`) because the discarded attribute is the defect itself, and a row whose repaired value would leave the plausible range is reported rather than written. Refs #944 --- docs/ops/apple-health-distance-repair.md | 121 +++++++++ scripts/README.md | 1 + scripts/repair-apple-health-distance.ts | 138 ++++++++++ .../repair-apple-health-distance.ts | 248 ++++++++++++++++++ .../apple-health-distance-repair.test.ts | 231 ++++++++++++++++ 5 files changed, 739 insertions(+) create mode 100644 docs/ops/apple-health-distance-repair.md create mode 100644 scripts/repair-apple-health-distance.ts create mode 100644 src/lib/measurements/repair-apple-health-distance.ts create mode 100644 tests/integration/apple-health-distance-repair.test.ts diff --git a/docs/ops/apple-health-distance-repair.md b/docs/ops/apple-health-distance-repair.md new file mode 100644 index 000000000..28b5c1c37 --- /dev/null +++ b/docs/ops/apple-health-distance-repair.md @@ -0,0 +1,121 @@ +# Apple Health walking-distance repair + +`scripts/repair-apple-health-distance.ts` heals walking distances that the +`export.zip` import stored a thousand times too small. + +## The defect + +Apple stamps every quantity `` in an `export.xml` with the account's +own display unit: a metric archive reports `HKQuantityTypeIdentifierDistance- +WalkingRunning` in `km`, an imperial one in `mi`. Until this release the record +mapping accepted that attribute and never read it, so the day's total was +folded as if the number were already metres. A 2.484 km day landed as +`2.484 m`, and an imported decade reads as a couple of metres walked. The +workout path was never affected — it converted its own distance unit. + +The code fix converts the record's unit before storage, so every import from +this release on is correct. This script is for the rows already in the +database. + +## Prefer a re-import + +If the account still has its `export.zip`, **re-import it**. The archive fold +UPSERTs the same `stats::` rows, so a re-import on the fixed +build rewrites every affected day with the right number, needs no flags, and +also repairs rows this script deliberately leaves alone (see the criterion +below). The script exists for accounts that no longer have the archive. + +## Which rows it touches + +Only rows carrying the stamp the archive importer itself writes: + +| Column | Value | +| ------------------------ | ------------------------------------------------------------ | +| `type` | `WALKING_RUNNING_DISTANCE` | +| `source` | `APPLE_HEALTH` | +| `aggregation_provenance` | `EXPORT_XML_SOURCE_MAX` | +| `external_id` | `stats:HKQuantityTypeIdentifierDistanceWalkingRunning:` | +| `deleted_at` | `NULL` | + +`EXPORT_XML_SOURCE_MAX` is written in exactly one place in the tree — the +archive fold — and the `stats:` external id is minted for that same fold, so +the pair is a proof of origin rather than a guess. Nothing is ever selected +because a value "looks too small". + +Deliberately out of scope: + +- Rows a native iOS sync has since overwritten (`HEALTHKIT_STATISTICS`): their + value came from HealthKit's own statistics query and is correct. +- Rows whose provenance is `LEGACY_UNKNOWN` or NULL: an archive origin cannot + be proven for them. Re-import the archive to repair those. + +## Run a dry-run first + +Always. The default mode reports and never writes: + +```bash +# from a source checkout, with DATABASE_URL pointing at the instance +pnpm dlx tsx scripts/repair-apple-health-distance.ts +``` + +It prints one line per account: how many rows would be repaired, the date span +they cover, one worked example (`2.484 m -> 2484 m`), and any row whose +repaired value would leave the plausibility range. + +This script imports the application dependency graph, so it runs from a source +checkout, not through the production image's `healthlog-tsx` launcher. + +## Name the archive's unit + +The stored row keeps no record of the unit its archive was written in — that +is the defect — so the operator names it. `--unit=km` is the default and is +what the Health app writes in every metric locale; pass `--unit=mi` for an +imperial archive: + +```bash +pnpm dlx tsx scripts/repair-apple-health-distance.ts --unit=mi +``` + +Check the dry-run's worked example against what the account's Health app shows +for that day before applying. If the two units are mixed across accounts, run +the script once per unit with `--apply` and confirm each account's example +first. + +## Apply + +```bash +pnpm dlx tsx scripts/repair-apple-health-distance.ts --apply +``` + +Per account, in one transaction: every selected row multiplied by the unit's +metre factor, `sync_version` incremented and `updated_at` bumped so paired iOS +clients pick the rows up on their next delta sync, and an audit row +(`measurement.apple_health_distance.repaired`) recording the unit, the factor +and the row count. The DAY/WEEK/MONTH/YEAR rollups and the cached status +insights for the touched days are recomputed afterwards. + +A row whose repaired value would leave the plausible range (0–200 000 m) is +reported and never written — several of those in one account mean the rows are +not the 1000x class and the run should be stopped. + +## It runs once per account + +The audit row is written inside the same transaction as the update, and an +account that carries it is skipped. A second run reports the account as already +repaired and changes nothing, so the multiplier can never be applied twice. + +## Verify + +```sql +SELECT date_trunc('year', measured_at) AS year, + round(avg(value)) AS avg_metres_per_day + FROM measurements + WHERE user_id = '' + AND type = 'WALKING_RUNNING_DISTANCE' + AND aggregation_provenance = 'EXPORT_XML_SOURCE_MAX' + GROUP BY 1 ORDER BY 1; +``` + +A plausible daily average is thousands of metres. Single digits mean the rows +were not repaired; millions mean a factor was applied twice, which the audit +row is there to prevent. diff --git a/scripts/README.md b/scripts/README.md index c4280328d..9eb2b0e6a 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -24,6 +24,7 @@ typecheck. - **`assert-deploy.ts`** — post-deploy `/api/version` assertion. - **`rotate-encryption-key.ts`** — AES-256-GCM key rotation; see `docs/ops/encryption-key-rotation.md`. - **`restore-backup.ts`** — restore an off-host AES-GCM backup from an S3-compatible bucket. +- **`repair-apple-health-distance.ts`** — repair walking-distance rows the `export.zip` import stored 1000x too small; dry-run by default, see `docs/ops/apple-health-distance-repair.md`. - **`repair-intake-anomalies.ts`** — repair historic medication-ledger defects (duplicate dose-slot rows, implausible `taken_at`); dry-run by default, see `docs/ops/intake-repair.md`. - **`backfill-rollups.ts`**, **`drain-per-sample-cumulative.ts`**, **`backfill-mood-note-column.ts`** — historical data backfills (note: the boot-time `rollup-full-backfill` queue handles new accounts automatically; the CLI is the manual fallback). - **`seed-demo.ts`** + **`seed-demo-v15.sql`** — populate a demo tenant. diff --git a/scripts/repair-apple-health-distance.ts b/scripts/repair-apple-health-distance.ts new file mode 100644 index 000000000..2abf0f45e --- /dev/null +++ b/scripts/repair-apple-health-distance.ts @@ -0,0 +1,138 @@ +/** + * scripts/repair-apple-health-distance.ts — operator repair for walking + * distances the `export.zip` import stored 1000x too small (issue #944). + * + * THE DEFECT (fixed in code from this release): the `` mapping read + * the value but never the record's own `unit` attribute, so a walking-distance + * record Apple writes in the account's display unit (`km` on a metric archive, + * `mi` on an imperial one) was folded into the day's total as if the number + * were already metres. Ten years of history read as a couple of metres a day. + * + * WHICH ROWS: only the ones carrying the stamp the archive importer writes — + * `WALKING_RUNNING_DISTANCE` + `APPLE_HEALTH` + provenance + * `EXPORT_XML_SOURCE_MAX` + a `stats:HKQuantityTypeIdentifierDistanceWalking- + * Running:` externalId. Never a guess from "the value looks small". Rows + * a native iOS sync has since overwritten carry `HEALTHKIT_STATISTICS` and are + * correct; rows whose provenance cannot prove an archive origin are left alone. + * + * If the account still has its `export.zip`, RE-IMPORTING it on the fixed build + * is the authoritative repair — the fold UPSERTs the same `stats:` rows with + * the right numbers. This script is for the accounts that no longer have it. + * + * SAFETY: + * - Dry-run by DEFAULT: lists the candidate rows per account and exits. + * - `--apply` writes, one transaction per account, and records an audit row + * in that same transaction. An account carrying that row is skipped, so a + * second run finds nothing. + * - A row whose repaired value would leave the plausibility range is + * reported and never written. + * - `--unit=mi` for an imperial archive (default `km`). The stored row keeps + * no record of the archive's unit — that is the defect — so the operator + * names it; a mismatched unit is why the flag exists rather than a guess. + * + * RUN (never `pnpm tsx` — the standalone image strips tsx): + * pnpm dlx tsx scripts/repair-apple-health-distance.ts # dry run + * pnpm dlx tsx scripts/repair-apple-health-distance.ts --apply + * pnpm dlx tsx scripts/repair-apple-health-distance.ts --unit=mi --apply + */ +import "dotenv/config"; + +import { prisma } from "@/lib/db"; +import { + applyAppleHealthDistanceRepair, + parseArchiveUnit, + planAppleHealthDistanceRepair, + repairFactorFor, +} from "@/lib/measurements/repair-apple-health-distance"; + +function readArchiveUnit(): "km" | "mi" { + const flag = process.argv.find((arg) => arg.startsWith("--unit=")); + if (!flag) return "km"; + const parsed = parseArchiveUnit(flag.slice("--unit=".length)); + if (!parsed) { + throw new Error(`Unsupported --unit value: ${flag}. Use km or mi.`); + } + return parsed; +} + +async function main(): Promise { + const apply = process.argv.includes("--apply"); + const archiveUnit = readArchiveUnit(); + const factor = repairFactorFor(archiveUnit); + + const plans = await planAppleHealthDistanceRepair(prisma, { archiveUnit }); + if (plans.length === 0) { + console.log( + "No export-imported walking-distance rows found — nothing to repair.", + ); + return; + } + + console.log( + `Archive unit ${archiveUnit} (x${factor} to metres). ` + + `${plans.length} account(s) carry export-imported walking distances.\n`, + ); + + let totalRepairable = 0; + let totalUpdated = 0; + let totalSkipped = 0; + + for (const plan of plans) { + if (plan.alreadyRepairedAt) { + console.log( + ` • ${plan.userId}: already repaired on ` + + `${plan.alreadyRepairedAt.toISOString()} — ${plan.candidateCount} row(s) left alone.`, + ); + continue; + } + + totalRepairable += plan.repairable.length; + const first = plan.repairable[0]; + const last = plan.repairable[plan.repairable.length - 1]; + console.log( + ` • ${plan.userId}: ${plan.repairable.length} row(s)` + + (first && last + ? ` from ${first.measuredAt.toISOString().slice(0, 10)} to ` + + `${last.measuredAt.toISOString().slice(0, 10)}` + : "") + + (plan.outOfRange.length > 0 + ? `, ${plan.outOfRange.length} out of plausible range (skipped)` + : ""), + ); + if (first) { + console.log( + ` e.g. ${first.id}: ${first.value} m -> ${first.value * factor} m`, + ); + } + for (const row of plan.outOfRange) { + console.log( + ` ! ${row.id} (${row.measuredAt.toISOString().slice(0, 10)}): ` + + `${row.value} m x ${factor} leaves the plausible range — not written`, + ); + } + + if (!apply) continue; + const outcome = await applyAppleHealthDistanceRepair(prisma, plan, { + archiveUnit, + }); + totalUpdated += outcome.updated; + totalSkipped += outcome.skipped; + console.log( + ` ✓ repaired ${outcome.updated} row(s), skipped ${outcome.skipped}`, + ); + } + + console.log( + apply + ? `\nDone. ${totalUpdated} row(s) repaired, ${totalSkipped} skipped.` + : `\nDry run: ${totalRepairable} row(s) would be repaired. ` + + "Re-run with --apply to write.", + ); +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/src/lib/measurements/repair-apple-health-distance.ts b/src/lib/measurements/repair-apple-health-distance.ts new file mode 100644 index 000000000..7257fa475 --- /dev/null +++ b/src/lib/measurements/repair-apple-health-distance.ts @@ -0,0 +1,248 @@ +/** + * Repair for walking distances the `export.zip` import stored 1000x too + * small (issue #944). + * + * THE DEFECT (fixed in code from this release): `mapAppleHealthEntry()` + * accepted the `` attribute and never read it, so a + * `HKQuantityTypeIdentifierDistanceWalkingRunning` record — which Apple + * writes in the account's own display unit, `km` on a metric archive — + * was folded into the day's total as if the number were already metres. + * Every day of an imported history reads as a couple of metres walked. + * + * THE CODE FIX converts the record's own unit into the table's `hkUnit` + * before storage, so every FUTURE import is right. This module heals what + * is already in the database. + * + * ── The criterion ────────────────────────────────────────────────── + * + * A row is a candidate only when it carries the stamp the export importer + * itself writes, never because its value "looks small": + * + * type = WALKING_RUNNING_DISTANCE + * source = APPLE_HEALTH + * aggregationProvenance = EXPORT_XML_SOURCE_MAX + * externalId LIKE stats:HKQuantityTypeIdentifierDistanceWalkingRunning:% + * deletedAt IS NULL + * + * `EXPORT_XML_SOURCE_MAX` is written in exactly one place — the archive + * fold in `import-apple-health-export.ts` — and the `stats::` + * externalId is minted by `dailyStatsExternalId()` for that same fold. A + * row a native iOS sync has since overwritten carries + * `HEALTHKIT_STATISTICS` and its value came from HealthKit's own + * statistics query, so it is correct and out of scope. A row whose + * provenance is `LEGACY_UNKNOWN` or NULL cannot be PROVEN to have come + * from the archive path and is therefore left alone: re-importing the + * archive on the fixed build repairs those, and is the authoritative + * repair for any account that still has its `export.zip`. + * + * ── Idempotency ──────────────────────────────────────────────────── + * + * Applying the repair writes one `AuditLog` row per account inside the + * same transaction as the update. An account that already carries that + * row is skipped, so a second run finds nothing to do — the multiplier + * can never be applied twice. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { auditLog } from "@/lib/auth/audit"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; +import { validateMeasurementRange } from "@/lib/validations/measurement"; + +import { dailyStatsExternalId } from "./apple-health-mapping"; +import { convertHkValue } from "./hk-units"; + +/** The audit action that marks an account's distances as repaired. */ +export const APPLE_HEALTH_DISTANCE_REPAIR_ACTION = + "measurement.apple_health_distance.repaired"; + +/** Minted by the same helper the export fold uses, so the two cannot drift. */ +export const EXPORT_DISTANCE_EXTERNAL_ID_PREFIX = dailyStatsExternalId( + "HKQuantityTypeIdentifierDistanceWalkingRunning", + "", +); + +/** + * The unit the affected archive was written in. Apple writes `km` on a + * metric account and `mi` on an imperial one, and the stored row keeps no + * record of which — the importer discarded the attribute, which is the + * defect. The operator names it; `km` is the default because it is what + * the Health app writes for every metric locale. + */ +export type ArchiveDistanceUnit = "km" | "mi"; + +export interface RepairCandidateRow { + id: string; + measuredAt: Date; + value: number; +} + +export interface AccountRepairPlan { + userId: string; + /** Candidate rows that would be multiplied. */ + repairable: RepairCandidateRow[]; + /** + * Candidates whose repaired value would leave the plausibility range + * (`validateMeasurementRange`) — reported, never written. A row here is + * a sign the account's rows are NOT the 1000x class. + */ + outOfRange: RepairCandidateRow[]; + /** Non-null when this account already carries the repair's audit row. */ + alreadyRepairedAt: Date | null; + /** Candidate rows found, whatever the disposition above. */ + candidateCount: number; +} + +/** The DB surface this module needs — the script hands it the real client. */ +export type RepairClient = Pick< + PrismaClient, + "measurement" | "auditLog" | "$transaction" +>; + +const REPAIRED_TYPE = "WALKING_RUNNING_DISTANCE" as const; + +/** metres per unit of the archive's own length unit. */ +export function repairFactorFor(unit: ArchiveDistanceUnit): number { + const factor = convertHkValue(1, unit, "m"); + // The two archive units are length units by construction; the shared + // table is the single source of the factor either way. + if (factor === null) throw new Error(`no metre factor for unit "${unit}"`); + return factor; +} + +/** + * List the repair candidates per account. Read-only — this is what the + * script's dry run prints. + */ +export async function planAppleHealthDistanceRepair( + client: RepairClient, + options: { archiveUnit: ArchiveDistanceUnit }, +): Promise { + const factor = repairFactorFor(options.archiveUnit); + + const rows = await client.measurement.findMany({ + where: { + type: REPAIRED_TYPE, + source: "APPLE_HEALTH", + aggregationProvenance: "EXPORT_XML_SOURCE_MAX", + externalId: { startsWith: EXPORT_DISTANCE_EXTERNAL_ID_PREFIX }, + deletedAt: null, + }, + select: { id: true, userId: true, value: true, measuredAt: true }, + orderBy: [{ userId: "asc" }, { measuredAt: "asc" }], + }); + if (rows.length === 0) return []; + + const userIds = Array.from(new Set(rows.map((row) => row.userId))); + const repaired = await client.auditLog.findMany({ + where: { + action: APPLE_HEALTH_DISTANCE_REPAIR_ACTION, + userId: { in: userIds }, + }, + select: { userId: true, createdAt: true }, + orderBy: { createdAt: "asc" }, + }); + const repairedAt = new Map(); + for (const entry of repaired) { + if (entry.userId) repairedAt.set(entry.userId, entry.createdAt); + } + + const plans = new Map(); + for (const row of rows) { + let plan = plans.get(row.userId); + if (!plan) { + plan = { + userId: row.userId, + repairable: [], + outOfRange: [], + alreadyRepairedAt: repairedAt.get(row.userId) ?? null, + candidateCount: 0, + }; + plans.set(row.userId, plan); + } + plan.candidateCount += 1; + if (plan.alreadyRepairedAt) continue; + const candidate: RepairCandidateRow = { + id: row.id, + measuredAt: row.measuredAt, + value: row.value, + }; + if (validateMeasurementRange(REPAIRED_TYPE, row.value * factor) !== null) { + plan.outOfRange.push(candidate); + } else { + plan.repairable.push(candidate); + } + } + return Array.from(plans.values()); +} + +export interface RepairOutcome { + userId: string; + updated: number; + skipped: number; +} + +/** + * Apply one account's plan: every repairable row multiplied by the + * archive unit's metre factor in ONE transaction, together with the audit + * row that makes a second run a no-op. Returns what it did. + */ +export async function applyAppleHealthDistanceRepair( + client: RepairClient, + plan: AccountRepairPlan, + options: { archiveUnit: ArchiveDistanceUnit }, +): Promise { + const factor = repairFactorFor(options.archiveUnit); + const skipped = plan.outOfRange.length; + if (plan.alreadyRepairedAt || plan.repairable.length === 0) { + return { userId: plan.userId, updated: 0, skipped }; + } + + const ids = plan.repairable.map((row) => row.id); + await client.$transaction(async (tx) => { + // One statement per chunk rather than one per row: an account with a + // decade of history carries thousands of rows and an interactive + // transaction has a clock on it. `sync_version` + `updated_at` are + // bumped by hand because raw SQL bypasses Prisma's `@updatedAt`, and + // the iOS delta feed pages on exactly those two columns. + for (let i = 0; i < ids.length; i += 500) { + const chunk = ids.slice(i, i + 500); + await tx.$executeRaw` + UPDATE measurements + SET value = value * ${factor}, + unit = 'm', + sync_version = sync_version + 1, + updated_at = NOW() + WHERE id = ANY(${chunk})`; + } + await auditLog(APPLE_HEALTH_DISTANCE_REPAIR_ACTION, { + client: tx, + userId: plan.userId, + actorUserId: null, + details: { + type: REPAIRED_TYPE, + archiveUnit: options.archiveUnit, + factor, + rows: ids.length, + skippedOutOfRange: skipped, + issue: 944, + }, + }); + }); + + // Best-effort tail: the DAY/WEEK/MONTH/YEAR rollups and the cached + // status insights hold the pre-repair numbers until they are recomputed. + await afterMeasurementMutation( + plan.userId, + plan.repairable.map((row) => ({ + type: REPAIRED_TYPE, + measuredAt: row.measuredAt, + })), + "repair-apple-health-distance", + ); + + return { userId: plan.userId, updated: ids.length, skipped }; +} + +/** Narrowing helper for the script's `--unit` flag. */ +export function parseArchiveUnit(raw: string): ArchiveDistanceUnit | null { + return raw === "km" || raw === "mi" ? raw : null; +} diff --git a/tests/integration/apple-health-distance-repair.test.ts b/tests/integration/apple-health-distance-repair.test.ts new file mode 100644 index 000000000..d4fa7bbea --- /dev/null +++ b/tests/integration/apple-health-distance-repair.test.ts @@ -0,0 +1,231 @@ +/** + * Integration coverage for the export-import walking-distance repair + * (issue #944). + * + * The defect stored every archive-imported `WALKING_RUNNING_DISTANCE` row a + * thousand times too small. `src/lib/measurements/repair-apple-health-distance.ts` + * heals what is already in the database, and everything that matters about it + * is a database property: which rows the criterion actually selects, that the + * multiply lands in one transaction with its audit row, and that a second run + * finds nothing. None of that can be proven against a mock, so this runs + * against the testcontainer Postgres (Docker / OrbStack; `pnpm test:integration`). + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { + APPLE_HEALTH_DISTANCE_REPAIR_ACTION, + applyAppleHealthDistanceRepair, + planAppleHealthDistanceRepair, +} from "@/lib/measurements/repair-apple-health-distance"; +import { settleBackgroundTasks } from "@/lib/logging/background-tasks"; +import type { + MeasurementAggregationProvenance, + MeasurementSource, + MeasurementType, + PrismaClient, +} from "@/generated/prisma/client"; + +const DISTANCE_PREFIX = "stats:HKQuantityTypeIdentifierDistanceWalkingRunning:"; + +let dayCounter = 0; + +async function seedRow( + prisma: PrismaClient, + userId: string, + overrides: { + value: number; + type?: MeasurementType; + source?: MeasurementSource; + provenance?: MeasurementAggregationProvenance | null; + externalId?: string; + }, +): Promise { + dayCounter += 1; + const measuredAt = new Date(Date.UTC(2026, 0, dayCounter, 12, 0, 0)); + const day = measuredAt.toISOString().slice(0, 10); + const row = await prisma.measurement.create({ + data: { + userId, + type: overrides.type ?? "WALKING_RUNNING_DISTANCE", + value: overrides.value, + unit: "m", + source: overrides.source ?? "APPLE_HEALTH", + measuredAt, + externalId: overrides.externalId ?? `${DISTANCE_PREFIX}${day}`, + aggregationProvenance: + overrides.provenance === undefined + ? "EXPORT_XML_SOURCE_MAX" + : overrides.provenance, + }, + select: { id: true }, + }); + return row.id; +} + +async function readRow(prisma: PrismaClient, id: string) { + return prisma.measurement.findUniqueOrThrow({ + where: { id }, + select: { value: true, unit: true, syncVersion: true }, + }); +} + +async function makeUser(prisma: PrismaClient, tag: string): Promise { + const user = await prisma.user.create({ + data: { + username: `distance-repair-${tag}`, + email: `distance-repair-${tag}@example.test`, + role: "USER", + }, + }); + return user.id; +} + +describe("Apple Health export distance repair — integration", () => { + beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + dayCounter = 0; + }); + + it("selects only rows the archive importer stamped, and repairs them once", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(prisma, "km"); + + const kmDay = await seedRow(prisma, userId, { value: 2.484 }); + const otherDay = await seedRow(prisma, userId, { value: 7.02 }); + // Decoys, one per arm of the criterion. + const nativeStats = await seedRow(prisma, userId, { + value: 8123, + provenance: "HEALTHKIT_STATISTICS", + }); + const legacyProvenance = await seedRow(prisma, userId, { + value: 3.4, + provenance: null, + }); + const manualRow = await seedRow(prisma, userId, { + value: 4.1, + source: "MANUAL", + }); + const otherType = await seedRow(prisma, userId, { + value: 9000, + type: "ACTIVITY_STEPS", + externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-01-09", + }); + const perSample = await seedRow(prisma, userId, { + value: 1.2, + externalId: "sample:abc123", + }); + + const plans = await planAppleHealthDistanceRepair(prisma, { + archiveUnit: "km", + }); + expect(plans).toHaveLength(1); + expect(plans[0].userId).toBe(userId); + expect(plans[0].repairable.map((row) => row.id).sort()).toEqual( + [kmDay, otherDay].sort(), + ); + expect(plans[0].alreadyRepairedAt).toBeNull(); + + const outcome = await applyAppleHealthDistanceRepair(prisma, plans[0], { + archiveUnit: "km", + }); + await settleBackgroundTasks(); + expect(outcome).toMatchObject({ userId, updated: 2, skipped: 0 }); + + expect((await readRow(prisma, kmDay)).value).toBeCloseTo(2484, 6); + expect((await readRow(prisma, otherDay)).value).toBeCloseTo(7020, 6); + // The delta feed pages on sync_version / updated_at, so a repaired row + // has to look mutated to a paired client. + expect((await readRow(prisma, kmDay)).syncVersion).toBe(2); + expect((await readRow(prisma, kmDay)).unit).toBe("m"); + + // Every decoy untouched. + expect((await readRow(prisma, nativeStats)).value).toBe(8123); + expect((await readRow(prisma, legacyProvenance)).value).toBe(3.4); + expect((await readRow(prisma, manualRow)).value).toBe(4.1); + expect((await readRow(prisma, otherType)).value).toBe(9000); + expect((await readRow(prisma, perSample)).value).toBe(1.2); + + const audit = await prisma.auditLog.findMany({ + where: { action: APPLE_HEALTH_DISTANCE_REPAIR_ACTION, userId }, + }); + expect(audit).toHaveLength(1); + expect(JSON.parse(audit[0].details ?? "{}")).toMatchObject({ + archiveUnit: "km", + factor: 1000, + rows: 2, + issue: 944, + }); + }); + + it("is idempotent — a second run finds nothing to repair", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(prisma, "twice"); + const rowId = await seedRow(prisma, userId, { value: 2.484 }); + + const first = await planAppleHealthDistanceRepair(prisma, { + archiveUnit: "km", + }); + await applyAppleHealthDistanceRepair(prisma, first[0], { + archiveUnit: "km", + }); + await settleBackgroundTasks(); + + const second = await planAppleHealthDistanceRepair(prisma, { + archiveUnit: "km", + }); + expect(second).toHaveLength(1); + expect(second[0].repairable).toHaveLength(0); + expect(second[0].candidateCount).toBe(1); + expect(second[0].alreadyRepairedAt).toBeInstanceOf(Date); + + const outcome = await applyAppleHealthDistanceRepair(prisma, second[0], { + archiveUnit: "km", + }); + expect(outcome.updated).toBe(0); + expect((await readRow(prisma, rowId)).value).toBeCloseTo(2484, 6); + expect((await readRow(prisma, rowId)).syncVersion).toBe(2); + }); + + it("uses the archive's own unit and never writes an implausible value", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(prisma, "mi"); + const mile = await seedRow(prisma, userId, { value: 1.543 }); + const absurd = await seedRow(prisma, userId, { value: 250 }); + + const plans = await planAppleHealthDistanceRepair(prisma, { + archiveUnit: "mi", + }); + expect(plans[0].repairable.map((row) => row.id)).toEqual([mile]); + expect(plans[0].outOfRange.map((row) => row.id)).toEqual([absurd]); + + const outcome = await applyAppleHealthDistanceRepair(prisma, plans[0], { + archiveUnit: "mi", + }); + await settleBackgroundTasks(); + expect(outcome).toMatchObject({ updated: 1, skipped: 1 }); + expect((await readRow(prisma, mile)).value).toBeCloseTo(2483.217792, 6); + expect((await readRow(prisma, absurd)).value).toBe(250); + }); + + it("plans one entry per account", async () => { + const prisma = getPrismaClient(); + const first = await makeUser(prisma, "a"); + const second = await makeUser(prisma, "b"); + await seedRow(prisma, first, { value: 1.5 }); + await seedRow(prisma, second, { value: 2.5 }); + await seedRow(prisma, second, { value: 3.5 }); + + const plans = await planAppleHealthDistanceRepair(prisma, { + archiveUnit: "km", + }); + expect( + plans.map((plan) => [plan.userId, plan.repairable.length]).sort(), + ).toEqual( + [ + [first, 1], + [second, 2], + ].sort(), + ); + }); +}); From bdfff049750505af89dc4ce96a135a6d4029fbb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 22:45:17 +0200 Subject: [PATCH 06/50] docs(api): say what the batch ingest does with a sample's unit The field carried no description while the server ignored it. It no longer ignores it, so the contract has to say so. Refs #944 --- docs/api/openapi.yaml | 3 +++ src/lib/openapi/routes/measurements.ts | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 72915fbc3..4e2fbce92 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -21350,6 +21350,9 @@ components: type: string minLength: 1 maxLength: 60 + description: Apple's `HKUnit` string for `value`. The server converts the reading into the unit it stores the identifier + in when the two differ and the factor is known (`km` to `m`, `lb` to `kg`, `degF` to `degC`, and their + siblings); a unit the server cannot place leaves the reading untouched. startDate: type: string format: date-time diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index ff655f406..756618059 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -53,7 +53,13 @@ const batchEntrySchema = z .describe( "v1.19.2 (iOS #34 extension) — the bucket's MAXIMUM bpm for a 10-minute heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.", ), - unit: z.string().min(1).max(60), + unit: z + .string() + .min(1) + .max(60) + .describe( + "Apple's `HKUnit` string for `value`. The server converts the reading into the unit it stores the identifier in when the two differ and the factor is known (`km` to `m`, `lb` to `kg`, `degF` to `degC`, and their siblings); a unit the server cannot place leaves the reading untouched.", + ), startDate: z.iso.datetime({ offset: true }), endDate: z.iso.datetime({ offset: true }), sleepStage: z From e031e2211c5756a5ac48d17f429a718694f6efeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 22:47:05 +0200 Subject: [PATCH 07/50] fix(import): honour the energy unit an archived workout carries `totalEnergyBurned` rides the same account-display-unit contract as the distance beside it: a Health app set to kilojoules writes `kJ`, and the column is kilocalories. Convert it through the shared table. Refs #944 --- .../import-apple-health-export.test.ts | 32 +++++++++++++++++++ .../import-apple-health-export.ts | 14 ++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/lib/measurements/__tests__/import-apple-health-export.test.ts b/src/lib/measurements/__tests__/import-apple-health-export.test.ts index c63637ec9..607a332b2 100644 --- a/src/lib/measurements/__tests__/import-apple-health-export.test.ts +++ b/src/lib/measurements/__tests__/import-apple-health-export.test.ts @@ -1297,6 +1297,38 @@ describe("streamParseExportXml — the record's own unit (issue #944)", () => { expect(prisma._measurements[0].value).toBeCloseTo(2484, 6); }); + it("stores a kilojoule workout burn in kilocalories", async () => { + const tmp = mkdtempSync(join(tmpdir(), "healthlog-parser-energy-test-")); + const xmlPath = join(tmp, "export.xml"); + writeFileSync( + xmlPath, + ` + + +`, + ); + const prisma = makeFakePrisma(); + await streamParseExportXml({ + xmlPath, + userId: "user-energy", + userTimezone: "Europe/Berlin", + prisma: prisma as unknown as PrismaClient, + }); + + expect(prisma._workouts).toHaveLength(1); + expect(prisma._workouts[0].totalDistanceM).toBeCloseTo(6500, 6); + expect(prisma._workouts[0].totalEnergyKcal).toBeCloseTo(412.0458891, 6); + }); + it("stores a mile-scale walking distance in metres", async () => { const { prisma } = await importCumulativeFixture( cumulativeExportXml( diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index 4a4bb9229..ea14ad8fe 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -42,7 +42,10 @@ import { } from "@/lib/measurements/drain-per-sample-cumulative"; import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement"; import { resolveHkWorkoutSportType } from "@/lib/measurements/hk-workout-activity-type-map"; -import { hkDistanceToMetres } from "@/lib/measurements/hk-units"; +import { + convertHkValue, + hkDistanceToMetres, +} from "@/lib/measurements/hk-units"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; @@ -948,6 +951,7 @@ export async function streamParseExportXml( const totalDistance = Number.parseFloat(attrs.totalDistance ?? ""); const totalEnergy = Number.parseFloat(attrs.totalEnergyBurned ?? ""); const distanceUnit = attrs.totalDistanceUnit ?? ""; + const energyUnit = attrs.totalEnergyBurnedUnit ?? ""; // Apple ships the ACCOUNT's own length unit on HKWorkout.totalDistance // (km for a metric account, mi for an imperial one); metres is the // canonical DB unit. issue #944 — the same conversion the `` @@ -969,7 +973,13 @@ export async function streamParseExportXml( startedAt: startDate, endedAt: endDate, durationSec, - totalEnergyKcal: Number.isFinite(totalEnergy) ? totalEnergy : null, + totalEnergyKcal: Number.isFinite(totalEnergy) + ? // issue #944 — the energy attribute carries the account's own + // unit too: a Health app set to kilojoules writes `kJ` here, and + // the column is kilocalories. Same shared conversion; an unknown + // unit leaves the number alone. + (convertHkValue(totalEnergy, energyUnit, "kcal") ?? totalEnergy) + : null, totalDistanceM: distanceM, externalId, externalSourceVersion: attrs.sourceVersion ?? null, From 626a95ff331156c1d8eea1f74fc4ca80e4ac4553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 22:47:31 +0200 Subject: [PATCH 08/50] ci: give every job that runs the unit suite a browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero-geometry check stopped skipping under CI in v1.38 and started failing instead, which was the point: it had caught its own launch failure and passed for months, measuring nothing. The install step went into the quality job of `security.yml` and nowhere else, and a second job runs the same suite — the auto-merge job for dependency bumps. Every Dependabot pull request has failed since on `browserType.launch: Executable doesn't exist`, four of them on 2026-09-06 alone (run 34043840314), on an error that says nothing about the bump. One workflow was fixed; the class was not. That job installs the same headless shell now, off the same cache key. The error the check throws names both jobs rather than sending a reader to the workflow that was already correct, and a guard sweeps the workflow directory so a third job cannot run the suite with no browser anywhere in it. Its limit is written down: the sweep is file-scoped, so a workflow with two jobs where only one installs still satisfies it. Gates: CI=1, browser path hidden exit=1, "This check is the gate in CI and it has no browser to measure with" CI=1, browser present exit=0, 4 tests passed (it measures) install step deleted from the guard fails: ["dependabot-auto-merge.yml"] auto-merge workflow --- .github/workflows/dependabot-auto-merge.yml | 17 ++++++ .../unit-suite-browser-install-guard.test.ts | 59 +++++++++++++++++++ .../health-score-card-geometry.test.tsx | 21 ++++--- 3 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/unit-suite-browser-install-guard.test.ts diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 7995cc21f..ef1038e90 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -31,6 +31,23 @@ jobs: - run: pnpm db:generate + # The hero-geometry check in the unit suite renders the real panel in a + # real browser and measures the boxes, and it refuses to skip when CI is + # set — a check that skips measures nothing. This job runs the same suite + # as the quality job in `security.yml` and needs the same browser: without + # it every Dependabot pull request went red on a launch failure that said + # nothing about the bump (four of them on 2026-09-06 alone). Headless + # shell only, matching that job. + - name: Cache Playwright headless shell + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-shell-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + playwright-shell-${{ runner.os }}- + + - run: pnpm exec playwright install --only-shell chromium + - run: pnpm test # Auto-merge is gated on the Dependabot-declared update type: diff --git a/src/__tests__/unit-suite-browser-install-guard.test.ts b/src/__tests__/unit-suite-browser-install-guard.test.ts new file mode 100644 index 000000000..aea369329 --- /dev/null +++ b/src/__tests__/unit-suite-browser-install-guard.test.ts @@ -0,0 +1,59 @@ +/** + * Every job that runs the unit suite installs the browser the suite needs. + * + * `health-score-card-geometry.test.tsx` renders the real hero band in a real + * Chromium and measures the boxes. It used to catch its own launch failure and + * pass, so for months it measured nothing in CI; since v1.38 it throws when + * `CI` is set instead, which is right — and which turns a job that runs + * `pnpm test` without a browser from silently useless into loudly red. + * + * That is exactly what happened. The install step was added to the quality job + * in `security.yml` and nowhere else, and `dependabot-auto-merge.yml` runs the + * same suite: every dependency bump failed on a launch error that said nothing + * about the bump. One workflow was fixed, one class was not. + * + * The sweep is textual and file-scoped, which is its limit: a workflow with two + * jobs, only one of which installs the browser, satisfies it. It catches the + * thing that actually went wrong — a whole workflow running the suite with no + * browser anywhere in it. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const WORKFLOWS = join(process.cwd(), ".github", "workflows"); + +/** `run: pnpm test` — the whole unit suite, geometry check included. */ +const RUNS_UNIT_SUITE = /^\s*(?:-\s*)?run:\s*pnpm test\s*$/m; +const INSTALLS_BROWSER = /playwright install --only-shell chromium/; + +describe("the browser the unit suite measures with", () => { + it("is installed by every workflow that runs the suite", () => { + const files = readdirSync(WORKFLOWS).filter((f) => f.endsWith(".yml")); + expect( + files.length, + "no workflow files found — the walk is broken, not the tree clean", + ).toBeGreaterThanOrEqual(5); + + const runners = files.filter((f) => + RUNS_UNIT_SUITE.test(readFileSync(join(WORKFLOWS, f), "utf8")), + ); + // An empty match set would agree with an empty violation list. It is two + // workflows today; pinned at one so removing a job is not a guard failure, + // but a matcher that stops matching is. + expect( + runners.length, + "no workflow runs `pnpm test` — the matcher stopped matching", + ).toBeGreaterThanOrEqual(1); + + const missing = runners.filter( + (f) => !INSTALLS_BROWSER.test(readFileSync(join(WORKFLOWS, f), "utf8")), + ); + expect( + missing, + "a workflow runs the unit suite without installing the headless " + + "Chromium the hero-geometry check measures with — that check refuses " + + "to skip under CI, so the job fails on a launch error instead", + ).toEqual([]); + }); +}); diff --git a/src/components/insights/__tests__/health-score-card-geometry.test.tsx b/src/components/insights/__tests__/health-score-card-geometry.test.tsx index 5e2b0711d..448e53764 100644 --- a/src/components/insights/__tests__/health-score-card-geometry.test.tsx +++ b/src/components/insights/__tests__/health-score-card-geometry.test.tsx @@ -223,16 +223,23 @@ beforeAll(async () => { browser = await chromium.launch(); } catch (error) { const reason = `no Chromium build available (${String(error).slice(0, 200)})`; - // In CI the browser is installed by the quality job, so a launch failure - // is the job being wrong, not the machine being bare. Throwing from the - // hook fails every test in this suite: a geometry check that quietly - // skips is the same thing as no geometry check, and this repo shipped - // exactly that for months because the skip only ever reached a console - // line nobody reads. + // In CI the browser is installed by the job that runs the suite, so a + // launch failure is the job being wrong, not the machine being bare. + // Throwing from the hook fails every test in this suite: a geometry check + // that quietly skips is the same thing as no geometry check, and this repo + // shipped exactly that for months because the skip only ever reached a + // console line nobody reads. + // + // TWO jobs run `pnpm test`, and naming only one of them here sent a reader + // to a workflow that was already correct: the quality job in + // `security.yml` and the auto-merge job in `dependabot-auto-merge.yml`. + // The second had no install step for three days after this check started + // failing instead of skipping, and turned every dependency bump red. if (process.env.CI) { throw new Error( `[hero geometry] ${reason}. This check is the gate in CI and it has no browser to measure with. ` + - `Restore the "pnpm exec playwright install --only-shell chromium" step in the quality job of .github/workflows/security.yml.`, + `Add the "pnpm exec playwright install --only-shell chromium" step to whichever job ran this suite — ` + + `the quality job of .github/workflows/security.yml, or the auto-merge job of .github/workflows/dependabot-auto-merge.yml.`, ); } skipReason = reason; From 69aac232b4b297685f6139638f73ded4f37ae055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 22:48:16 +0200 Subject: [PATCH 09/50] build: budget the routes on measured drift, not on a ceiling they touch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the four watched routes sat within a kilobyte of their ceiling — 444 of 445, 459 of 460, 459 of 460 — because the numbers had been re-stated for four releases and never re-based. A budget a route is already touching does not measure the route; it measures whichever dependency bump landed that week. e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with nothing in the diff that touched the bundle, and it was one of at least four e2e failures that were the budget gate rather than a test. The file states the measured value per route now, plus one drift allowance over it, and a route fails on the delta. Measured on a local build at d48bbff01: / 447, /insights 434, /measurements 433, /insights/mood 447, allowance 20 KB, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta and ordinary week-to-week drift; the step change the gate exists for, a statically imported catalog or a duplicated chart library, is several times larger and still fails. Every run prints the delta, so a route that has crept most of the way through its allowance is legible in the log long before it is red. The reason and the re-base instructions are written into the file's running note, as with every raise before it. A budget file with no baselines used to gate nothing quietly, since the loop had nothing to walk; it now refuses. Gates, against the current build: real baselines passed; /page +0.3, /insights +0.3, /measurements +0.2, /insights/mood +0.3 KB /page baseline 425 (cap 445), FAILED: "/page: 447 KB gz is 21.6 KB over the /insights 413 (cap 433) 425 KB baseline, past the 20 KB drift allowance (cap 445 KB)" and the same for /insights/page, exit 1 routeBaselineKbGz removed exit 2, "states no routeBaselineKbGz entries, so no route is gated" --- bundle-budget.json | 13 ++++---- scripts/check-bundle-budget.mjs | 59 +++++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/bundle-budget.json b/bundle-budget.json index 868b80aa6..d0777b781 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,11 +1,12 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch \u2014 the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands \u2014 here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up.", - "routesKbGz": { - "/page": 460, - "/insights/page": 445, - "/measurements/page": 445, - "/insights/mood/page": 460 + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before.", + "routeBaselineKbGz": { + "/page": 447, + "/insights/page": 434, + "/measurements/page": 433, + "/insights/mood/page": 447 }, + "routeDriftAllowanceKbGz": 20, "totalClientKbGz": 3700, "maxRechartsChunks": 1 } diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs index cd2518981..f53c6f44d 100644 --- a/scripts/check-bundle-budget.mjs +++ b/scripts/check-bundle-budget.mjs @@ -23,11 +23,20 @@ * pnpm bundle-report # print the report table * pnpm bundle-report --check # enforce bundle-budget.json (CI gate) * - * Budgets live in `bundle-budget.json` (repo root). Numbers are KB gzip - * with deliberate headroom over the measured value — the gate exists to - * catch step-change regressions (a ~100 KB catalog, a ~90 KB duplicate - * library), not day-to-day noise. Raise a budget consciously, in the same - * PR that pays the cost, with the reason in the PR body. + * Budgets live in `bundle-budget.json` (repo root). Numbers are KB gzip. + * + * Per-route the file states a MEASURED baseline and one drift allowance + * over it, and a route fails on the delta rather than on a ceiling it is + * already touching. Flat ceilings had been re-stated but never re-based, so + * three routes ended up sitting within a kilobyte of theirs and any + * unrelated dependency bump failed the e2e job on a regression nobody had + * made. The delta is printed on every run, so a route that has crept most of + * the way through its allowance is readable in the log before it is red. + * + * The gate still exists to catch step changes (a ~100 KB statically imported + * catalog, a ~90 KB duplicate library), which are several times the + * allowance. Re-base consciously, in the same PR that pays the cost, with + * the reason written into `$comment`. */ import { readFileSync, readdirSync, existsSync } from "node:fs"; @@ -166,14 +175,28 @@ console.log( console.log(`recharts-fingerprint chunks: ${rechartsChunks}`); console.log(""); -const watched = budget ? Object.keys(budget.routesKbGz ?? {}) : []; +const baselines = budget?.routeBaselineKbGz ?? {}; +const allowanceKb = budget?.routeDriftAllowanceKbGz ?? 0; +/** The ceiling a route's measured baseline plus the drift allowance buys it. */ +const capFor = (route) => + baselines[route] == null ? null : baselines[route] + allowanceKb; + +const watched = Object.keys(baselines); const rows = [...routeTotals.entries()] .filter(([route]) => !watched.length || watched.includes(route)) .sort((a, b) => b[1].gz - a[1].gz); for (const [route, { gz }] of rows.slice(0, watched.length || 15)) { - const cap = budget?.routesKbGz?.[route]; + const baseline = baselines[route]; + if (baseline == null) { + console.log(`${fmt(gz).padStart(8)} gz ${route}`); + continue; + } + const delta = kb(gz) - baseline; + const sign = delta >= 0 ? "+" : "−"; console.log( - `${fmt(gz).padStart(8)} gz ${route}${cap ? ` (budget ${cap} KB)` : ""}`, + `${fmt(gz).padStart(8)} gz ${route} (baseline ${baseline} KB, ` + + `${sign}${Math.abs(delta).toFixed(1)} KB of ${allowanceKb} KB allowed, ` + + `cap ${capFor(route)} KB)`, ); } @@ -185,16 +208,30 @@ if (!budget) { process.exit(2); } +// A budget file that states no baselines gates nothing, and would do it +// quietly — the loop below would simply have nothing to walk. Say so instead. +if (watched.length === 0) { + console.error( + "check-bundle-budget: bundle-budget.json states no routeBaselineKbGz " + + "entries, so no route is gated. Measure the eager routes and write " + + "them in, with a routeDriftAllowanceKbGz over them.", + ); + process.exit(2); +} + const failures = []; -for (const [route, capKb] of Object.entries(budget.routesKbGz ?? {})) { +for (const [route, baselineKb] of Object.entries(baselines)) { const actual = routeTotals.get(route); if (!actual) { failures.push(`route ${route} not found in the build output`); continue; } - if (kb(actual.gz) > capKb) { + const delta = kb(actual.gz) - baselineKb; + if (delta > allowanceKb) { failures.push( - `${route}: ${fmt(actual.gz)} gz exceeds the ${capKb} KB budget`, + `${route}: ${fmt(actual.gz)} gz is ${delta.toFixed(1)} KB over the ` + + `${baselineKb} KB baseline, past the ${allowanceKb} KB drift ` + + `allowance (cap ${capFor(route)} KB)`, ); } } From 7ca69ec671075783d899f64cdc208b4504ed1dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 22:55:53 +0200 Subject: [PATCH 10/50] test(integration): stop the rate-limit reset test racing its own window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window-reset test burned its budget in a 50 ms window: four sequential round-trips to a containerised Postgres had to finish inside a twentieth of a second or the window closed under them, the counter reset, and the fourth call came back allowed. Three failures in thirty days, two of them on `main` — run 33614804032 shows `expected true to be false` at rate-limit.test.ts:56 in a file that took 1278 ms to run. The short window bought nothing: the expiry this test is about is forced by hand a few lines further down, by writing `reset_at` into the past. The burn now runs in a 60 s window, so no round-trip can outlast it, and the test asserts the row's count is 4 after the denial — a window that closed mid-burn would also have produced three allowed calls, and the old assertion could not tell the two apart. Gates: pnpm exec vitest run --config vitest.integration.config.mts \ tests/integration/rate-limit.test.ts three consecutive runs, 2 passed / 2 passed / 2 passed. Mutation: with the burn window back at 1 ms the run fails exactly as CI did — "AssertionError: expected true to be false" on the denial assertion. --- tests/integration/rate-limit.test.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/integration/rate-limit.test.ts b/tests/integration/rate-limit.test.ts index fa0d3b494..3f9d8e7ee 100644 --- a/tests/integration/rate-limit.test.ts +++ b/tests/integration/rate-limit.test.ts @@ -47,14 +47,30 @@ describe("checkRateLimit (real Postgres)", () => { const key = "test:reset:1.2.3.4"; const limit = 3; - // Burn the budget with a short 50ms window. + // Burn the budget in a window wide enough that it cannot close under the + // four calls that fill it. It used to be 50 ms, which asked the four + // round-trips to a containerised Postgres to finish inside a twentieth of + // a second: on a loaded runner the window expired mid-burn, the counter + // reset, and the fourth call came back allowed — three failures in thirty + // days, two of them on `main` (run 33614804032, `expected true to be + // false` on the line below, in a file that took 1278 ms to run). The short + // window bought nothing either way, because the expiry this test is about + // is forced by hand a few lines down rather than waited for. + const windowMs = 60_000; for (let i = 0; i < 3; i++) { - const r = await checkRateLimit(key, limit, 50); + const r = await checkRateLimit(key, limit, windowMs); expect(r.allowed).toBe(true); } - const denied = await checkRateLimit(key, limit, 50); + const denied = await checkRateLimit(key, limit, windowMs); expect(denied.allowed).toBe(false); + // The denial has to be the cap, not a window that closed under the burn — + // a reset would also have produced three allowed calls before it. + const burned = await getPrismaClient().rateLimit.findUnique({ + where: { key }, + }); + expect(burned?.count).toBe(4); + // Manually expire the window: cheaper than sleeping and avoids // flaky timing on slow CI runners. The branch under test compares // `reset_at < NOW()`, so any past timestamp triggers the reset. From cee6c706383ad68b4d594a98c6152ee81ca13ef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:02:46 +0200 Subject: [PATCH 11/50] test(a11y): gate the metric scans on the chart's own slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two metric sub-page cases waited for `.recharts-wrapper`, a class the charting library writes and every chart on the page carries, so the gate was satisfied by whichever chart rendered first rather than by the one the route exists to show. On `/insights/mood` that is exactly what happened: the mock carried a single mood entry on a fixed date, the chart withholds its line below three distinct days, and the sparse placeholder painted while some other chart answered the gate. The mood line was never waited for and may never have been scanned. Both charts now carry `data-slot="chart-plot"` on the wrapper their data branch renders — an empty window still paints the empty state and the scan still waits — and the mood fixture is anchored on now with ten daily scores, the same way the measurement rows above it are. --- e2e/a11y.spec.ts | 40 +++++++++++++++++++------- src/components/charts/health-chart.tsx | 5 ++++ src/components/charts/mood-chart.tsx | 4 +++ 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index cbdf84d14..febb55d52 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -75,6 +75,20 @@ const fixedMeasurementRows = Array.from({ length: 10 }, (_, index) => ({ notes: null, })); +// Ten daily mood scores ending yesterday, anchored for the same reason the +// measurement rows above are: the mood chart windows by calendar day and +// withholds its line below three DISTINCT days, so a fixed date left the mood +// sub-page painting its sparse placeholder. The scan's painted gate was then +// satisfied by a different chart on the page, and the mood line — the reason +// the case exists — was never waited for and might never have been scanned. +const fixedMoodEntries = Array.from({ length: 10 }, (_, index) => ({ + date: new Date(Date.now() - (index + 1) * 86_400_000) + .toISOString() + .slice(0, 10), + score: 3 + (index % 3) * 0.5, + samples: 1, +})); + const workoutListEntry = { id: A11Y_WORKOUT_ID, sportType: "running", @@ -330,8 +344,8 @@ async function installA11yMocks(page: Page) { await page.route("**/api/mood/analytics", (route) => fulfilJson(route, { - entries: [{ date: "2026-07-20", score: 4 }], - summary: { count: 1 }, + entries: fixedMoodEntries, + summary: { count: fixedMoodEntries.length }, }), ); @@ -342,11 +356,8 @@ async function installA11yMocks(page: Page) { // present-but-empty; the correlations block carries every metric key. await page.route("**/api/mood/insights", (route) => fulfilJson(route, { - summary: { totalEntries: 1, inTargetPct: null }, - heatmap: { - windowDays: 30, - cells: [{ date: "2026-07-20", score: 4, samples: 1 }], - }, + summary: { totalEntries: fixedMoodEntries.length, inTargetPct: null }, + heatmap: { windowDays: 30, cells: fixedMoodEntries }, distribution: [], weekday: [], timeOfDay: { buckets: [], reliable: false, best: null, worst: null }, @@ -712,18 +723,25 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [ }), }, { + // `.recharts-wrapper` is a class the charting library writes, not a + // contract this repository keeps, and it is shared by every chart on the + // page — so the gate could be satisfied by a chart other than the one the + // route exists to render, and it moves whenever the library's markup does. + // `chart-plot` is the measurement chart's own slot and the data branch is + // the only branch that renders it: an empty window paints `ChartEmptyState` + // instead and this scan still waits, which is what it is for. name: "/insights/weight metric subpage", path: "/insights/weight", - painted: (page) => page.locator(".recharts-wrapper").first(), + painted: (page) => page.locator('[data-slot="chart-plot"]').first(), }, { // Mood is event-driven, so its sub-page renders the heatmap and the mood // line chart from `/api/mood/insights` rather than a MeasurementType series. - // Gate on the line chart's recharts wrapper — the same painted signal the - // weight sub-page uses — so the scan waits for the heavy content to land. + // The mood chart carries the same `chart-plot` slot on the same terms, so + // the scan waits for the heavy content to land here too. name: "/insights/mood metric subpage", path: "/insights/mood", - painted: (page) => page.locator(".recharts-wrapper").first(), + painted: (page) => page.locator('[data-slot="chart-plot"]').first(), }, { // The two states this page's OWN render produces: the list, or the diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 675dd643c..6948494cf 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1945,6 +1945,11 @@ export function HealthChart({ ) : null}
From 99991cd4f3596d42468374068078c0a147735bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:02:53 +0200 Subject: [PATCH 12/50] test(documents): wait for the vault to book the Coach hand-off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maximize case clicked the drawer's maximize control as soon as the drawer was on screen. Those are not the same event: the vault records `?doc=` as the way back only once the detail sheet has actually closed with the drawer owning the same document, and maximizing before that sends the close down the other branch, which calls `history.back()` and races the push to `/coach`. The URL then stays on `/documents` and nothing is raised. The sheet's own close control leaves the tree with the sheet, so its absence is that hand-off, observed. The delete case is addressed to the toast's attributes rather than its copy. The literal there has already been wrong once — a trailing full stop moved and the assertion spent its whole timeout hunting a sentence that no longer existed, on every attempt, on both projects. --- e2e/documents.spec.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index c8dc20e21..838d66342 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -345,6 +345,16 @@ test.describe("document vault", () => { await detail.locator('[data-slot="document-chat-open"]').click(); const drawer = page.locator('[data-slot="coach-drawer"]'); await expect(drawer).toBeVisible(); + // The drawer being on screen is not the same event as the vault having + // recorded the hand-off. The page books `?doc=` as the way back only once + // the detail sheet is actually closed with the drawer owning the same + // document; maximize before that and the close path takes the other branch + // — `history.back()` — which races the push to `/coach` and lands back on + // `/documents` with nothing raised. The sheet's own close control leaves + // the tree with the sheet, so its absence is that hand-off, observed. + await expect( + page.locator('[data-slot="document-detail-close"]'), + ).toHaveCount(0); await drawer.locator('[data-slot="coach-drawer-maximize"]').click(); await expect.poll(() => new URL(page.url()).pathname).toBe("/coach"); expect(new URL(page.url()).searchParams.get("doc")).toBe(MRT_DOC_ID); @@ -392,11 +402,19 @@ test.describe("document vault", () => { await sheet.getByRole("button", { name: "Delete" }).click(); // Undo toast → restore → the card returns. - await expect( - page.getByText("Document deleted", { exact: true }), - ).toBeVisible(); + // + // Addressed to the toast's own attributes, not to its copy. The copy is + // i18n-driven and the literal here has already been wrong once — a trailing + // full stop moved and the assertion spent its whole timeout looking for a + // sentence that no longer existed, on every attempt, on both projects. The + // success type is what the delete claims and the action button is what the + // undo needs; both are structural. + const undo = page.locator( + '[data-sonner-toast][data-type="success"] [data-button]', + ); + await expect(undo).toBeVisible(); await expect(openButton(page, `${title}.pdf`)).not.toBeVisible(); - await page.getByRole("button", { name: "Undo" }).click(); + await undo.click(); await expect(openButton(page, `${title}.pdf`)).toBeVisible(); }); From 0bf54b48eb10cce584b14d655ca954af7ae7163c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:02:59 +0200 Subject: [PATCH 13/50] test(vaccinations): seed the a11y dose over the page's own connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan's fixture was written with a Playwright API context. Every API context in a runner shares one process-wide keep-alive agent, so a request can be handed a socket the server has already closed on its five-second idle timeout, and a POST — unlike a GET — is never replayed on a fresh one. That is what ended this spec twice, on this line, on all three attempts each time: `socket hang up` and `read ECONNRESET`. The dose is written from the page instead. The browser opens its own connection, the service worker is blocked in this project, and the row still goes through the real route, so nothing about the fixture changes except which socket carries it. --- e2e/vaccinations.spec.ts | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/e2e/vaccinations.spec.ts b/e2e/vaccinations.spec.ts index 42f8f5ef4..e46fc2b95 100644 --- a/e2e/vaccinations.spec.ts +++ b/e2e/vaccinations.spec.ts @@ -282,16 +282,34 @@ test.describe("vaccinations", () => { test("the list and the capture form carry no serious accessibility violations", async ({ page, - request, }) => { // Seed one dose so the list scans a populated surface rather than the empty // state, then scan both the list and the open capture form. - const seeded = await request.post("/api/vaccinations", { - data: { occurredAt: "2019-05-01T00:00:00.000Z", antigenSlug: "polio" }, - }); - expect(seeded.status()).toBe(201); - + // + // Written from the page, not from a Playwright API context. Every API + // context in the runner shares ONE process-wide keep-alive agent + // (`httpHappyEyeballsAgent`), so a request can be handed a pooled socket the + // server has already closed on its five-second idle timeout — and a POST, + // unlike a GET, is never replayed on a fresh one. This seed died that way + // twice, on this line, on all three attempts each time: `socket hang up` + // and `read ECONNRESET`. The browser opens its own connection and the row + // is still written by the real route, so nothing about the fixture changes + // except which socket carries it. await page.goto("/vaccinations"); + const seededStatus = await page.evaluate(async () => { + const res = await fetch("/api/vaccinations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + occurredAt: "2019-05-01T00:00:00.000Z", + antigenSlug: "polio", + }), + }); + return res.status; + }); + expect(seededStatus).toBe(201); + + await page.reload(); await expect( page.locator('[data-slot="vaccination-group"][data-antigen="polio"]'), ).toBeVisible({ timeout: 15_000 }); From 2b3c01738e5f7839b7865b0e01272b2aebb85225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:03:05 +0200 Subject: [PATCH 14/50] test(fence): switch the record over the page's own connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FENCE-AC-07 drove its external switch through the browser context's API request context, which shares one process-wide keep-alive agent with every other API context in the runner. A POST there can be handed a connection the server has already closed on its five-second idle timeout, and a POST is never replayed on a fresh one — twice this line ended the spec with `read ECONNRESET`, on all three attempts. The switch is sent with the page's own `fetch`. It stays external in the sense the case needs: the switch UI is what runs the cache wipe and a bare `fetch` is not it, and the service worker returns without touching any non-GET, so the request reaches the network unaltered. The positive control below still proves the cached entry survived. --- e2e/v137-record-session-fence-offline.spec.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/e2e/v137-record-session-fence-offline.spec.ts b/e2e/v137-record-session-fence-offline.spec.ts index d2e281d6e..13faa6b88 100644 --- a/e2e/v137-record-session-fence-offline.spec.ts +++ b/e2e/v137-record-session-fence-offline.spec.ts @@ -411,12 +411,28 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { expect(stored!.epoch).toMatch(/^\d+$/); expect(stored!.scope).not.toBe("self"); - // The EXTERNAL switch: driven from a request context, so the app never - // runs its cache wipe and the entry above survives. - const left = await context.request.post("/api/account/switch", { - data: { accountId: null }, + // The EXTERNAL switch: nothing in the app initiates it, so + // `clearOfflineCachesForRecordSwitch` never runs and the entry above + // survives — the positive control below is what proves that. + // + // Sent with the page's own `fetch` rather than through + // `context.request`. Both are outside the app: the switch UI is what runs + // the wipe, and a bare `fetch` is not it (the service worker returns + // without touching any non-GET, so the request reaches the network + // unaltered). What differs is the socket. Every Playwright API context in + // the runner shares one process-wide keep-alive agent, so this POST could + // be handed a connection the server had already closed on its five-second + // idle timeout, and a POST is never replayed on a fresh one — twice this + // line ended the spec with `read ECONNRESET`, on all three attempts. + const left = await page.evaluate(async () => { + const res = await fetch("/api/account/switch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: null }), + }); + return res.status; }); - expect(left.status()).toBe(200); + expect(left).toBe(200); const me = await context.request.get("/api/auth/me"); const now = (await me.json()).data.recordSession as { epoch: number; From 9a2b1f0955cfc72cbaef06222a7912d8543b6bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:03:13 +0200 Subject: [PATCH 15/50] feat(glucose): name the bucket for readings with no meal-time context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `glucose_context` is nullable, but every surface that fanned out over the four enum members treated the named list as exhaustive. A meter synced through Apple Health writes no HealthKit meal-time metadata at all, so an account can hold nothing but rows the list cannot match. Add one shared notion of the untagged bucket — the name the Coach's glucose block already used — with a resolver that files NULL, blank and unrecognised values under it, and a grouper that walks the named contexts first and the untagged bucket last. The untagged bucket borrows the RANDOM threshold: an untagged reading is a spot reading, and a fifth threshold would give the user a second editor row judging the same readings by a different number. Labels in all seven locales. Refs #943 --- messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/ko.json | 3 + messages/pl.json | 3 + src/lib/__tests__/glucose.test.ts | 49 +++++++++++++++++ src/lib/glucose.ts | 91 +++++++++++++++++++++++++++++-- 9 files changed, 157 insertions(+), 4 deletions(-) diff --git a/messages/de.json b/messages/de.json index eef3089fe..9a3d0cb6c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -208,6 +208,7 @@ "typeGlucosePostprandial": "Glukose (postprandial)", "typeGlucoseRandom": "Glukose (beliebig)", "typeGlucoseBedtime": "Glukose (vor dem Schlafen)", + "typeGlucoseUnspecified": "Glukose (ohne Angabe)", "glucoseClassificationTitle": "Blutzucker — Referenzbereiche (ADA 2024 / DDG)", "glucoseRow": "{label}: Ø {avg} {unit} (Ziel {rangeMin}–{rangeMax}) — {class}", "glucoseInTarget": "im Ziel", @@ -3438,6 +3439,7 @@ "postprandial": "Nach dem Essen", "random": "Beliebig", "bedtime": "Vor dem Schlafen", + "unspecified": "Ohne Angabe", "readingsShort": "{count} Messungen", "readingsShortOne": "1 Messung" } @@ -7593,6 +7595,7 @@ "glucosePostprandial": "Glukose — postprandial", "glucoseRandom": "Glukose — beliebig", "glucoseBedtime": "Glukose — vor dem Schlafen", + "glucoseUnspecified": "Glukose — ohne Angabe", "status": { "underweight": "Untergewicht", "normal": "Normal", diff --git a/messages/en.json b/messages/en.json index e75e342a8..ae324a483 100644 --- a/messages/en.json +++ b/messages/en.json @@ -244,6 +244,7 @@ "typeGlucosePostprandial": "Glucose (postprandial)", "typeGlucoseRandom": "Glucose (random)", "typeGlucoseBedtime": "Glucose (bedtime)", + "typeGlucoseUnspecified": "Glucose (untagged)", "glucoseClassificationTitle": "Blood glucose — reference ranges (ADA 2024 / DDG)", "glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}", "glucoseInTarget": "in target", @@ -3438,6 +3439,7 @@ "postprandial": "After meals", "random": "Random", "bedtime": "Bedtime", + "unspecified": "Untagged", "readingsShort": "{count} readings", "readingsShortOne": "1 reading" } @@ -7593,6 +7595,7 @@ "glucosePostprandial": "Glucose — post-meal", "glucoseRandom": "Glucose — random", "glucoseBedtime": "Glucose — bedtime", + "glucoseUnspecified": "Glucose — untagged", "status": { "underweight": "Underweight", "normal": "Normal", diff --git a/messages/es.json b/messages/es.json index 91f6187d8..3fb6c1d8c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -208,6 +208,7 @@ "typeGlucosePostprandial": "Glucosa (posprandial)", "typeGlucoseRandom": "Glucosa (aleatoria)", "typeGlucoseBedtime": "Glucosa (antes de dormir)", + "typeGlucoseUnspecified": "Glucosa (sin indicar)", "glucoseClassificationTitle": "Glucemia — Rangos de referencia (ADA 2024 / DDG)", "glucoseRow": "{label}: med. {avg} {unit} (objetivo {rangeMin}–{rangeMax}) — {class}", "glucoseInTarget": "en objetivo", @@ -3438,6 +3439,7 @@ "postprandial": "Después de comer", "random": "Aleatorio", "bedtime": "Antes de dormir", + "unspecified": "Sin indicar", "readingsShort": "{count} mediciones", "readingsShortOne": "1 medición" } @@ -7593,6 +7595,7 @@ "glucosePostprandial": "Glucemia — posprandial", "glucoseRandom": "Glucemia — cualquiera", "glucoseBedtime": "Glucemia — antes de dormir", + "glucoseUnspecified": "Glucemia — sin indicar", "status": { "underweight": "Peso insuficiente", "normal": "Normal", diff --git a/messages/fr.json b/messages/fr.json index 23921c63d..08a2b848f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -208,6 +208,7 @@ "typeGlucosePostprandial": "Glycémie (postprandiale)", "typeGlucoseRandom": "Glycémie (indifférente)", "typeGlucoseBedtime": "Glycémie (avant le coucher)", + "typeGlucoseUnspecified": "Glycémie (non précisée)", "glucoseClassificationTitle": "Glycémie — Plages de référence (ADA 2024 / DDG)", "glucoseRow": "{label} : moy. {avg} {unit} (cible {rangeMin}–{rangeMax}) — {class}", "glucoseInTarget": "dans la cible", @@ -3438,6 +3439,7 @@ "postprandial": "Après les repas", "random": "Aléatoire", "bedtime": "Au coucher", + "unspecified": "Non précisée", "readingsShort": "{count} mesures", "readingsShortOne": "1 mesure" } @@ -7593,6 +7595,7 @@ "glucosePostprandial": "Glycémie — postprandiale", "glucoseRandom": "Glycémie — indifférente", "glucoseBedtime": "Glycémie — avant le coucher", + "glucoseUnspecified": "Glycémie — non précisée", "status": { "underweight": "Insuffisance pondérale", "normal": "Normal", diff --git a/messages/it.json b/messages/it.json index e17e31091..bb61e24ba 100644 --- a/messages/it.json +++ b/messages/it.json @@ -208,6 +208,7 @@ "typeGlucosePostprandial": "Glicemia (postprandiale)", "typeGlucoseRandom": "Glicemia (qualsiasi)", "typeGlucoseBedtime": "Glicemia (prima di coricarsi)", + "typeGlucoseUnspecified": "Glicemia (non indicata)", "glucoseClassificationTitle": "Glicemia — Intervalli di riferimento (ADA 2024 / DDG)", "glucoseRow": "{label}: media {avg} {unit} (obiettivo {rangeMin}–{rangeMax}) — {class}", "glucoseInTarget": "in obiettivo", @@ -3438,6 +3439,7 @@ "postprandial": "Dopo i pasti", "random": "Casuale", "bedtime": "Prima di dormire", + "unspecified": "Non indicata", "readingsShort": "{count} misurazioni", "readingsShortOne": "1 misurazione" } @@ -7593,6 +7595,7 @@ "glucosePostprandial": "Glicemia — postprandiale", "glucoseRandom": "Glicemia — qualsiasi", "glucoseBedtime": "Glicemia — prima di coricarsi", + "glucoseUnspecified": "Glicemia — non indicata", "status": { "underweight": "Sottopeso", "normal": "Normal", diff --git a/messages/ko.json b/messages/ko.json index 66d228404..37af1bca3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -244,6 +244,7 @@ "typeGlucosePostprandial": "혈당 (식후)", "typeGlucoseRandom": "혈당 (임의)", "typeGlucoseBedtime": "혈당 (취침 전)", + "typeGlucoseUnspecified": "혈당 (미지정)", "glucoseClassificationTitle": "혈당 — 참고 범위 (ADA 2024 / DDG)", "glucoseRow": "{label}: 평균 {avg} {unit} (목표 {rangeMin}–{rangeMax}) — {class}", "glucoseInTarget": "목표 범위 내", @@ -3438,6 +3439,7 @@ "postprandial": "식후", "random": "임의", "bedtime": "취침 전", + "unspecified": "미지정", "readingsShort": "측정값 {count}개", "readingsShortOne": "측정값 1개" } @@ -7593,6 +7595,7 @@ "glucosePostprandial": "혈당 — 식후", "glucoseRandom": "혈당 — 무작위", "glucoseBedtime": "혈당 — 취침 전", + "glucoseUnspecified": "혈당 — 미지정", "status": { "underweight": "저체중", "normal": "정상", diff --git a/messages/pl.json b/messages/pl.json index 7ec4d5cbc..da7723c8b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -208,6 +208,7 @@ "typeGlucosePostprandial": "Glukoza (po posiłku)", "typeGlucoseRandom": "Glukoza (dowolna)", "typeGlucoseBedtime": "Glukoza (przed snem)", + "typeGlucoseUnspecified": "Glukoza (bez oznaczenia)", "glucoseClassificationTitle": "Glikemia — zakresy referencyjne (ADA 2024 / DDG)", "glucoseRow": "{label}: śr. {avg} {unit} (cel {rangeMin}–{rangeMax}) — {class}", "glucoseInTarget": "w celu", @@ -3438,6 +3439,7 @@ "postprandial": "Po posiłku", "random": "Losowy", "bedtime": "Przed snem", + "unspecified": "Bez oznaczenia", "readingsShort": "{count} pomiarów", "readingsShortOne": "1 pomiar" } @@ -7593,6 +7595,7 @@ "glucosePostprandial": "Glikemia — po posiłku", "glucoseRandom": "Glikemia — dowolna", "glucoseBedtime": "Glikemia — przed snem", + "glucoseUnspecified": "Glikemia — bez oznaczenia", "status": { "underweight": "Niedowaga", "normal": "Normal", diff --git a/src/lib/__tests__/glucose.test.ts b/src/lib/__tests__/glucose.test.ts index db296dccd..a684ba896 100644 --- a/src/lib/__tests__/glucose.test.ts +++ b/src/lib/__tests__/glucose.test.ts @@ -6,6 +6,9 @@ import { toCanonicalMgdl, resolveGlucoseUnit, thresholdMetricForContext, + glucoseContextBucket, + groupByGlucoseContext, + GLUCOSE_CONTEXT_UNSPECIFIED, } from "../glucose"; describe("glucose conversion", () => { @@ -108,3 +111,49 @@ describe("glucose conversion", () => { expect(toCanonicalMgdl(mgdlToMmol(70), "mmol/L")).toBe(70); }); }); + +describe("glucose context buckets (#943)", () => { + it("files NULL, blank and unknown values under the untagged bucket", () => { + expect(glucoseContextBucket(null)).toBe(GLUCOSE_CONTEXT_UNSPECIFIED); + expect(glucoseContextBucket(undefined)).toBe(GLUCOSE_CONTEXT_UNSPECIFIED); + expect(glucoseContextBucket("")).toBe(GLUCOSE_CONTEXT_UNSPECIFIED); + expect(glucoseContextBucket(" ")).toBe(GLUCOSE_CONTEXT_UNSPECIFIED); + expect(glucoseContextBucket("BEFORE_MEAL")).toBe( + GLUCOSE_CONTEXT_UNSPECIFIED, + ); + }); + + it("keeps the named contexts named, case- and whitespace-tolerant", () => { + expect(glucoseContextBucket("FASTING")).toBe("FASTING"); + expect(glucoseContextBucket(" bedtime ")).toBe("BEDTIME"); + }); + + it("groups in canonical order with the untagged bucket last", () => { + const rows = [ + { ctx: null, v: 1 }, + { ctx: "BEDTIME", v: 2 }, + { ctx: "FASTING", v: 3 }, + { ctx: "", v: 4 }, + ]; + const grouped = groupByGlucoseContext(rows, (row) => row.ctx); + + expect(grouped.map(([bucket]) => bucket)).toEqual([ + "FASTING", + "BEDTIME", + "UNSPECIFIED", + ]); + // Input order survives inside a bucket, so a caller reading "latest" off + // the head keeps reading the same row. + expect(grouped[2][1].map((row) => row.v)).toEqual([1, 4]); + }); + + it("returns nothing for no rows", () => { + expect(groupByGlucoseContext([], () => null)).toEqual([]); + }); + + it("judges the untagged bucket against the RANDOM threshold", () => { + expect(thresholdMetricForContext(GLUCOSE_CONTEXT_UNSPECIFIED)).toBe( + "BLOOD_GLUCOSE_RANDOM", + ); + }); +}); diff --git a/src/lib/glucose.ts b/src/lib/glucose.ts index 14d2dee5b..4b4e69a61 100644 --- a/src/lib/glucose.ts +++ b/src/lib/glucose.ts @@ -49,11 +49,93 @@ export function resolveGlucoseUnit( } /** - * Threshold metric key corresponding to a glucose context. Keeps the + * The `GlucoseContext` enum members, in the order every surface iterates them. + */ +export const NAMED_GLUCOSE_CONTEXTS = [ + "FASTING", + "POSTPRANDIAL", + "RANDOM", + "BEDTIME", +] as const; + +export type NamedGlucoseContext = (typeof NAMED_GLUCOSE_CONTEXTS)[number]; + +/** + * The bucket every reading lands in whose `glucoseContext` column is NULL, + * blank, or not one of the four enum members. + * + * It is not a `GlucoseContext` value and never reaches the database — the + * column stays NULL. It exists because a reading without a meal-time tag is + * still a reading, and a surface that iterates only the named contexts drops + * it silently. That is not hypothetical: a meter synced through Apple Health + * writes no HealthKit meal-time metadata at all, so such an account has one + * hundred percent of its readings here and saw an empty dashboard, empty + * targets, and an empty doctor-report panel while the data was current + * (#943). The Coach's glucose block already grouped them under the same + * name — this promotes that one bucket to the shared notion the rest of the + * tree resolves through. + */ +export const GLUCOSE_CONTEXT_UNSPECIFIED = "UNSPECIFIED"; + +export type GlucoseContextBucket = + NamedGlucoseContext | typeof GLUCOSE_CONTEXT_UNSPECIFIED; + +/** Canonical iteration order: the named contexts first, untagged last. */ +export const GLUCOSE_CONTEXT_BUCKETS = [ + ...NAMED_GLUCOSE_CONTEXTS, + GLUCOSE_CONTEXT_UNSPECIFIED, +] as const; + +/** + * Resolve a stored `glucose_context` cell to the bucket it belongs to. + * NULL, an empty string, and any value outside the enum all resolve to + * {@link GLUCOSE_CONTEXT_UNSPECIFIED} — the fail-open arm, because dropping + * a reading is worse than filing it under "no meal-time tag". + */ +export function glucoseContextBucket( + raw: string | null | undefined, +): GlucoseContextBucket { + if (raw == null) return GLUCOSE_CONTEXT_UNSPECIFIED; + const key = raw.trim().toUpperCase(); + return (NAMED_GLUCOSE_CONTEXTS as readonly string[]).includes(key) + ? (key as NamedGlucoseContext) + : GLUCOSE_CONTEXT_UNSPECIFIED; +} + +/** + * Group rows into their glucose buckets, in {@link GLUCOSE_CONTEXT_BUCKETS} + * order, skipping buckets with no rows. Row order inside a bucket is the + * caller's input order, so a caller that reads "latest" off the head or tail + * keeps doing so. + */ +export function groupByGlucoseContext( + rows: readonly T[], + contextOf: (row: T) => string | null | undefined, +): Array<[GlucoseContextBucket, T[]]> { + const byBucket = new Map(); + for (const row of rows) { + const bucket = glucoseContextBucket(contextOf(row)); + const list = byBucket.get(bucket); + if (list) list.push(row); + else byBucket.set(bucket, [row]); + } + return GLUCOSE_CONTEXT_BUCKETS.flatMap((bucket) => { + const list = byBucket.get(bucket); + return list ? [[bucket, list] as [GlucoseContextBucket, T[]]] : []; + }); +} + +/** + * Threshold metric key corresponding to a glucose bucket. Keeps the * effective-range resolver the single source of truth. + * + * The untagged bucket borrows the RANDOM band: a reading with no meal-time + * tag IS a spot reading, which is exactly what RANDOM means, and inventing a + * fifth threshold would give the user a second editor row that judges the + * same readings by a different number. */ export function thresholdMetricForContext( - context: "FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME", + context: GlucoseContextBucket, ): | "BLOOD_GLUCOSE_FASTING" | "BLOOD_GLUCOSE_POSTPRANDIAL" @@ -64,9 +146,10 @@ export function thresholdMetricForContext( return "BLOOD_GLUCOSE_FASTING"; case "POSTPRANDIAL": return "BLOOD_GLUCOSE_POSTPRANDIAL"; - case "RANDOM": - return "BLOOD_GLUCOSE_RANDOM"; case "BEDTIME": return "BLOOD_GLUCOSE_BEDTIME"; + case "RANDOM": + case GLUCOSE_CONTEXT_UNSPECIFIED: + return "BLOOD_GLUCOSE_RANDOM"; } } From 5ab7a2ca81fc1d522cb1309e79e1156035c99377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:03:21 +0200 Subject: [PATCH 16/50] fix(dashboard): paint the blood-glucose tile for untagged readings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tile eligibility filtered the four named contexts against the per-context summaries and asked whether any of them held readings. An account whose source records no meal time matched none of them, so the tile was omitted with the module on, the layout toggle on, and a reading from today. Nothing warned; the strip was simply short one card. Move the gate out of the page into `resolveGlucoseTiles`, which walks the shared bucket list — untagged included — and hand the caller the label key with the summary. Both summary producers group through the shared resolver, so `UNSPECIFIED` reaches the wire with its own count, latest value and trend, and a tagged account still gets its per-context breakdown. Refs #943 --- docs/api/openapi.yaml | 6 +- src/app/api/analytics/route.ts | 19 ++--- src/app/page-client.tsx | 33 +++----- .../__tests__/dashboard-gates-glucose.test.ts | 79 +++++++++++++++++++ src/components/dashboard/dashboard-gates.ts | 47 +++++++++++ src/lib/dashboard/snapshot.ts | 25 +++--- src/lib/openapi/routes/analytics.ts | 2 +- src/lib/openapi/routes/insights/schemas.ts | 2 +- 8 files changed, 162 insertions(+), 51 deletions(-) create mode 100644 src/components/dashboard/__tests__/dashboard-gates-glucose.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 72915fbc3..b165aa8c6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -49994,9 +49994,9 @@ components: additionalProperties: $ref: "#/components/schemas/MetricDataSummary" - type: "null" - description: Per-context glucose summaries (FASTING / POSTPRANDIAL / RANDOM / BEDTIME) over the trailing 30 days, - canonical mg/dL. Contexts with no readings are omitted; the whole block is null when the glucose module is - off. + description: Per-context glucose summaries (FASTING / POSTPRANDIAL / RANDOM / BEDTIME, plus UNSPECIFIED for readings + whose source recorded no meal-time context) over the trailing 30 days, canonical mg/dL. Buckets with no + readings are omitted; the whole block is null when the glucose module is off. glucoseClinical: anyOf: - $ref: "#/components/schemas/GlucoseClinicalMetrics" diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 351181531..02056525c 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -34,6 +34,7 @@ import { GLUCOSE_PANEL_WINDOW_DAYS, type GlucoseClinicalMetrics, } from "@/lib/analytics/glucose-metrics"; +import { groupByGlucoseContext } from "@/lib/glucose"; import { decisionForEvidence, PATTERN_FAMILIES, @@ -495,16 +496,16 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { orderBy: { measuredAt: "asc" }, select: { value: true, measuredAt: true, glucoseContext: true }, }); + // #943 — grouped through the shared bucket resolver, so readings with no + // meal-time tag land in `UNSPECIFIED` instead of being dropped on the floor. const glucoseByContext: Record> = {}; - if (glucoseRows.length > 0) { - const contexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const; - for (const ctx of contexts) { - const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx); - if (ctxRows.length === 0) continue; - glucoseByContext[ctx] = summarize( - ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })), - ); - } + for (const [bucket, ctxRows] of groupByGlucoseContext( + glucoseRows, + (r) => r.glucoseContext, + )) { + glucoseByContext[bucket] = summarize( + ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })), + ); } // v1.17.0 — server-authoritative glucose clinical panel. The TIR / GMI / diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 182c42526..4ea9f0e11 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -174,6 +174,7 @@ import { resolveConfiguredTileCount, resolveChartRowPlaceholderCount, pickHrvSummary, + resolveGlucoseTiles, } from "@/components/dashboard/dashboard-gates"; /** @@ -725,23 +726,12 @@ export default function DashboardPageClient({ const glucoseWidgetVisible = isTileVisible("glucose"); const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null); const glucoseByContext = data?.glucoseByContext ?? {}; - const glucoseContexts = [ - "FASTING", - "POSTPRANDIAL", - "RANDOM", - "BEDTIME", - ] as const; - const glucoseSummariesPresent = glucoseContexts.filter( - (ctx) => (glucoseByContext[ctx]?.count ?? 0) > 0, - ); - const showGlucoseCards = - glucoseWidgetVisible && glucoseSummariesPresent.length > 0; - const glucoseLabelKey: Record = { - FASTING: "targets.glucoseFasting", - POSTPRANDIAL: "targets.glucosePostprandial", - RANDOM: "targets.glucoseRandom", - BEDTIME: "targets.glucoseBedtime", - }; + // #943 — eligibility comes from `resolveGlucoseTiles`, which walks the + // untagged bucket alongside the four named contexts. The gate used to be + // an inline filter over the named contexts only, so an account whose + // source never writes a meal-time tag saw nothing. + const glucoseTiles = resolveGlucoseTiles(glucoseByContext); + const showGlucoseCards = glucoseWidgetVisible && glucoseTiles.length > 0; // v1.18.6 — band / target math is computed SERVER-side in the snapshot // DTO (`targetBands`) so the client stops recomputing it from the // profile (audit finding #3). When snapshot mode is on, read the @@ -1634,16 +1624,15 @@ export default function DashboardPageClient({ } if (showGlucoseCards) { const glucoseOrder = widgetOrder("glucose"); - glucoseSummariesPresent.forEach((ctx, idx) => { - const s = glucoseByContext[ctx]; + glucoseTiles.forEach(({ bucket, labelKey, summary: s }, idx) => { trendCards.push({ - id: `glucose-${ctx}`, + id: `glucose-${bucket}`, // sub-order so all glucose cards stay in a block and order stable order: glucoseOrder + idx / 1000, node: ( = {}): DataSummary { + return { + count: 12, + latest: 118, + min: 92, + max: 149, + mean: 117, + median: 116, + avg7: 119, + avg30: 117, + slope7: null, + slope30: { slope: 0.4, direction: "up", confidence: 0.6 }, + ...overrides, + }; +} + +describe("resolveGlucoseTiles (#943)", () => { + it("is eligible when every reading is untagged", () => { + const tiles = resolveGlucoseTiles({ + UNSPECIFIED: summary({ count: 358, latest: 104 }), + }); + + expect(tiles).toHaveLength(1); + expect(tiles[0].bucket).toBe("UNSPECIFIED"); + expect(tiles[0].summary.latest).toBe(104); + expect(tiles[0].summary.slope30).not.toBeNull(); + }); + + it("keeps the named breakdown and appends the untagged bucket last", () => { + const tiles = resolveGlucoseTiles({ + FASTING: summary({ count: 5 }), + BEDTIME: summary({ count: 3 }), + UNSPECIFIED: summary({ count: 9 }), + }); + + expect(tiles.map((tile) => tile.bucket)).toEqual([ + "FASTING", + "BEDTIME", + "UNSPECIFIED", + ]); + }); + + it("stays ineligible when nothing is logged", () => { + expect(resolveGlucoseTiles(undefined)).toEqual([]); + expect(resolveGlucoseTiles({})).toEqual([]); + expect(resolveGlucoseTiles({ UNSPECIFIED: summary({ count: 0 }) })).toEqual( + [], + ); + }); + + it("names every bucket it emits", () => { + const tiles = resolveGlucoseTiles({ + RANDOM: summary(), + UNSPECIFIED: summary(), + }); + + for (const tile of tiles) { + expect(tile.labelKey).toMatch(/^targets\.glucose/); + } + }); +}); diff --git a/src/components/dashboard/dashboard-gates.ts b/src/components/dashboard/dashboard-gates.ts index e583da023..80577cffa 100644 --- a/src/components/dashboard/dashboard-gates.ts +++ b/src/components/dashboard/dashboard-gates.ts @@ -7,6 +7,10 @@ * `../page` test imports stay valid. */ import type { DataSummary } from "@/lib/analytics/trends"; +import { + GLUCOSE_CONTEXT_BUCKETS, + type GlucoseContextBucket, +} from "@/lib/glucose"; import type { DashboardLayout } from "@/lib/dashboard-layout"; /** @@ -207,3 +211,46 @@ export function pickHrvSummary( // no HRV at all look like a ring user. return pick(primary ?? null, "HEART_RATE_VARIABILITY"); } + +/** + * The i18n key naming each glucose bucket on the tile strip. The untagged + * bucket is named, not blank: a strip of cards where one carries no label + * reads as a rendering fault, and the user needs to know the readings behind + * it are the ones their meter never tagged. + */ +export const GLUCOSE_TILE_LABEL_KEY: Record = { + FASTING: "targets.glucoseFasting", + POSTPRANDIAL: "targets.glucosePostprandial", + RANDOM: "targets.glucoseRandom", + BEDTIME: "targets.glucoseBedtime", + UNSPECIFIED: "targets.glucoseUnspecified", +}; + +/** One resolved glucose strip tile. */ +export interface GlucoseTile { + bucket: GlucoseContextBucket; + labelKey: string; + summary: DataSummary; +} + +/** + * Which glucose tiles the strip paints, in the canonical bucket order. An + * empty result means the tile is not eligible and the whole glucose block is + * omitted. + * + * #943 — the untagged bucket is in the loop, not outside it. Gating on the + * four named contexts alone hid the tile from every account whose source + * writes no meal-time tag, with no warning anywhere: the module was on, the + * layout toggle was on, the readings were current, and the strip was empty. + */ +export function resolveGlucoseTiles( + byContext: Record | undefined, +): GlucoseTile[] { + const tiles: GlucoseTile[] = []; + for (const bucket of GLUCOSE_CONTEXT_BUCKETS) { + const summary = byContext?.[bucket]; + if (!summary || summary.count <= 0) continue; + tiles.push({ bucket, labelKey: GLUCOSE_TILE_LABEL_KEY[bucket], summary }); + } + return tiles; +} diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 9e403895d..da3f1f631 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -49,6 +49,7 @@ import { GLUCOSE_PANEL_WINDOW_DAYS, type GlucoseClinicalMetrics, } from "@/lib/analytics/glucose-metrics"; +import { groupByGlucoseContext } from "@/lib/glucose"; import { computeBpInTargetFastPath, type BpInTargetEnvelope, @@ -134,13 +135,6 @@ import { unavailableWidgetIds, } from "@/lib/dashboard/widget-modules"; -const GLUCOSE_CONTEXTS = [ - "FASTING", - "POSTPRANDIAL", - "RANDOM", - "BEDTIME", -] as const; - /** * v1.7.0 — HealthLog `MeasurementType` → iOS `MetricKind` raw value. * @@ -832,15 +826,16 @@ async function buildExtras( orderBy: { measuredAt: "asc" }, select: { value: true, measuredAt: true, glucoseContext: true }, }); + // #943 — grouped through the shared bucket resolver, so readings with no + // meal-time tag land in `UNSPECIFIED` instead of being dropped on the floor. const glucoseByContext: Record = {}; - if (glucoseRows.length > 0) { - for (const ctx of GLUCOSE_CONTEXTS) { - const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx); - if (ctxRows.length === 0) continue; - glucoseByContext[ctx] = summarize( - ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })), - ); - } + for (const [bucket, ctxRows] of groupByGlucoseContext( + glucoseRows, + (r) => r.glucoseContext, + )) { + glucoseByContext[bucket] = summarize( + ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })), + ); } // v1.17.0 — clinical panel from the SAME 30-day glucose rows already read diff --git a/src/lib/openapi/routes/analytics.ts b/src/lib/openapi/routes/analytics.ts index 0dd258dfb..f51360fee 100644 --- a/src/lib/openapi/routes/analytics.ts +++ b/src/lib/openapi/routes/analytics.ts @@ -507,7 +507,7 @@ const analyticsDefaultResponse = z .record(z.string(), dataSummary) .nullable() .describe( - "Per-context glucose summaries (FASTING / POSTPRANDIAL / RANDOM / BEDTIME) over the trailing 30 days, canonical mg/dL. Contexts with no readings are omitted; the whole block is null when the glucose module is off.", + "Per-context glucose summaries (FASTING / POSTPRANDIAL / RANDOM / BEDTIME, plus UNSPECIFIED for readings whose source recorded no meal-time context) over the trailing 30 days, canonical mg/dL. Buckets with no readings are omitted; the whole block is null when the glucose module is off.", ), glucoseClinical: glucoseClinicalSchema .nullable() diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 6e76f57ee..2fa522b70 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -2178,7 +2178,7 @@ export const analyticsDefaultSliceResponse = z .record(z.string(), dataSummary) .nullable() .describe( - "Per-context summaries (`FASTING` / `POSTPRANDIAL` / `RANDOM` / `BEDTIME`) over the trailing 30 days, canonical mg/dL. A context with no readings is omitted. NULL — not an empty object — when the glucose module is off.", + "Per-context summaries (`FASTING` / `POSTPRANDIAL` / `RANDOM` / `BEDTIME`, plus `UNSPECIFIED` for readings whose source recorded no meal-time context) over the trailing 30 days, canonical mg/dL. A bucket with no readings is omitted. NULL — not an empty object — when the glucose module is off.", ), glucoseClinical: glucoseClinicalSchema .nullable() From 971c922a143af3d264a6077789a89c7f27c1ca73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:03:32 +0200 Subject: [PATCH 17/50] fix(targets): give untagged glucose readings a target card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildGlucoseTargets` had the same gap as the dashboard tile: it iterated the four named contexts, so an account whose readings carry no meal time got no glucose target at all. Route the loop through the shared grouper. The untagged card is judged against the RANDOM band and is not editable — one threshold, one editor row — so the cog renders disabled the way it does for a derived card. The insights reference panel lists the new target type, otherwise such an account sees no band on the blood-glucose sub-page. Refs #943 --- .../insights/metric-target-summary.tsx | 4 ++ src/components/targets/target-edit-sheet.tsx | 4 ++ src/lib/targets/__tests__/builders.test.ts | 62 +++++++++++++++++++ src/lib/targets/glucose-builder.ts | 28 ++++----- src/lib/targets/glucose-targets.ts | 17 ++++- 5 files changed, 98 insertions(+), 17 deletions(-) diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index 81fdc0277..cfdaa0be4 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -119,6 +119,10 @@ const GLUCOSE_TARGET_TYPES = [ "BLOOD_GLUCOSE_POSTPRANDIAL", "BLOOD_GLUCOSE_RANDOM", "BLOOD_GLUCOSE_BEDTIME", + // #943 — readings whose source recorded no meal-time context. The route + // emits this card whenever such rows exist, so the panel must list it or + // an account with only untagged readings gets no reference band at all. + "BLOOD_GLUCOSE_UNSPECIFIED", ]; interface MetricTargetSummaryProps { diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index c69acb2ce..d3a788d42 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -69,6 +69,10 @@ const TARGET_TYPE_TO_METRIC: Record = { // BLOOD_PRESSURE handled specially with both sys + dia. // BMI / MOOD_SCORE / MOOD_STABILITY / MEDICATION_COMPLIANCE / // BLOOD_PRESSURE_IN_TARGET — derived, no editable threshold. + // BLOOD_GLUCOSE_UNSPECIFIED (#943) — deliberately absent. The untagged + // bucket is judged against the RANDOM band; giving it its own editor row + // would offer two controls that write one threshold, so the cog renders + // disabled the same way it does for a derived card. }; interface CurrentRange { diff --git a/src/lib/targets/__tests__/builders.test.ts b/src/lib/targets/__tests__/builders.test.ts index efb34f1ff..8362cda40 100644 --- a/src/lib/targets/__tests__/builders.test.ts +++ b/src/lib/targets/__tests__/builders.test.ts @@ -135,6 +135,68 @@ describe("target section builders", () => { }); }); + it("emits a card for readings with no meal-time context (#943)", () => { + // A meter synced through Apple Health writes no HealthKit meal-time + // metadata, so every row arrives with a null context. Iterating the four + // named contexts alone returned no glucose target at all. + const targets = buildGlucoseTargets({ + rows: [ + { value: 112, measuredAt: NOW, glucoseContext: null }, + { value: 104, measuredAt: NOW, glucoseContext: null }, + ], + profile: { + heightCm: 180, + dateOfBirth: new Date("1985-01-01T00:00:00.000Z"), + gender: "MALE", + glucoseUnit: "mg/dL", + hasDiabetes: false, + thresholdsJson: null, + }, + timezone: TZ, + now: NOW, + }); + + expect(targets.map((target) => target.type)).toEqual([ + "BLOOD_GLUCOSE_UNSPECIFIED", + ]); + expect(targets[0]).toMatchObject({ + label: "targets.glucoseUnspecified", + current: 112, + average30: 108, + unit: "mg/dL", + }); + // The untagged bucket is judged against the RANDOM band, so it still + // carries a range and a classification rather than a blank card. + expect(targets[0].range).not.toBeNull(); + expect(targets[0].classification).not.toBeNull(); + }); + + it("keeps the tagged breakdown and puts the untagged bucket last (#943)", () => { + const targets = buildGlucoseTargets({ + rows: [ + { value: 130, measuredAt: NOW, glucoseContext: "RANDOM" as const }, + { value: 90, measuredAt: NOW, glucoseContext: "FASTING" as const }, + { value: 111, measuredAt: NOW, glucoseContext: null }, + ], + profile: { + heightCm: 180, + dateOfBirth: new Date("1985-01-01T00:00:00.000Z"), + gender: "MALE", + glucoseUnit: "mg/dL", + hasDiabetes: false, + thresholdsJson: null, + }, + timezone: TZ, + now: NOW, + }); + + expect(targets.map((target) => target.type)).toEqual([ + "BLOOD_GLUCOSE_FASTING", + "BLOOD_GLUCOSE_RANDOM", + "BLOOD_GLUCOSE_UNSPECIFIED", + ]); + }); + it("keeps summary streak ties stable by public target order", () => { expect( buildTargetPageSummary([ diff --git a/src/lib/targets/glucose-builder.ts b/src/lib/targets/glucose-builder.ts index 01fab7c6f..cea599c92 100644 --- a/src/lib/targets/glucose-builder.ts +++ b/src/lib/targets/glucose-builder.ts @@ -1,21 +1,18 @@ -import type { GlucoseContext } from "@/generated/prisma/client"; -import { resolveGlucoseUnit } from "@/lib/glucose"; +import { + groupByGlucoseContext, + resolveGlucoseUnit, + type GlucoseContextBucket, +} from "@/lib/glucose"; import { resolveGlucoseTarget } from "./glucose-targets"; import { makeRangeClassifier, rollupConsistency } from "./consistency"; import type { TargetGlucoseRow, TargetItem, TargetProfile } from "./types"; -const GLUCOSE_CONTEXTS: GlucoseContext[] = [ - "FASTING", - "POSTPRANDIAL", - "RANDOM", - "BEDTIME", -]; - -const LABEL_BY_CONTEXT: Record = { +const LABEL_BY_CONTEXT: Record = { FASTING: "targets.glucoseFasting", POSTPRANDIAL: "targets.glucosePostprandial", RANDOM: "targets.glucoseRandom", BEDTIME: "targets.glucoseBedtime", + UNSPECIFIED: "targets.glucoseUnspecified", }; interface GlucoseTargetsInput { @@ -35,10 +32,13 @@ export function buildGlucoseTargets({ const unit = resolveGlucoseUnit(profile.glucoseUnit); const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - for (const context of GLUCOSE_CONTEXTS) { - const contextRows = rows.filter((row) => row.glucoseContext === context); - if (contextRows.length === 0) continue; - + // #943 — the untagged bucket rides the same loop. Iterating the four named + // contexts alone left an account whose source writes no meal-time tag with + // no glucose target at all, the same gap the dashboard tile had. + for (const [context, contextRows] of groupByGlucoseContext( + rows, + (row) => row.glucoseContext, + )) { const latest = contextRows[0].value; const recent = contextRows.filter((row) => row.measuredAt >= thirtyDaysAgo); const average30 = diff --git a/src/lib/targets/glucose-targets.ts b/src/lib/targets/glucose-targets.ts index 4653c1e7b..a0ffec64b 100644 --- a/src/lib/targets/glucose-targets.ts +++ b/src/lib/targets/glucose-targets.ts @@ -40,10 +40,17 @@ import { type UserProfileForRange, } from "@/lib/analytics/effective-range"; import type { TrafficRange } from "@/lib/analytics/value-bands"; -import { thresholdMetricForContext } from "@/lib/glucose"; +import { + thresholdMetricForContext, + type GlucoseContextBucket, +} from "@/lib/glucose"; -export type GlucoseContextKey = - "FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME"; +/** + * The buckets a glucose target can be resolved for — the four named contexts + * plus the untagged bucket (#943), which is judged against the RANDOM band + * because an untagged reading is a spot reading. + */ +export type GlucoseContextKey = GlucoseContextBucket; /** * ADA glycemic GOAL bands for people living with diabetes (mg/dL). @@ -59,6 +66,9 @@ export type GlucoseContextKey = * higher floor guards against nocturnal hypoglycemia. Kept identical to the * non-diabetic bedtime band on purpose — the diabetic delta lives in the * fasting/post-prandial goals, where ADA is explicit. + * UNSPECIFIED: a reading whose source recorded no meal-time context. Same band + * as RANDOM, and for the same reason — an untagged reading is a spot + * reading, and the post-prandial goal is the defensible comparator. */ const DIABETIC_GOAL_BANDS: Record< GlucoseContextKey, @@ -68,6 +78,7 @@ const DIABETIC_GOAL_BANDS: Record< POSTPRANDIAL: { min: 80, max: 180 }, RANDOM: { min: 80, max: 180 }, BEDTIME: { min: 90, max: 150 }, + UNSPECIFIED: { min: 80, max: 180 }, }; /** Source label surfaced on the resolved range for the targets surface. */ From 126383b35280c2e1f4c4ee6949897e682a4aa241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:03:32 +0200 Subject: [PATCH 18/50] fix(doctor-report): keep untagged glucose readings in the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both report paths dropped them: the raw path iterated the named contexts, and the dense path skipped any row without a context outright, so a report built for a meter that records no meal time carried an empty glucose panel while the readings sat in the database. The FHIR export dropped them for the third time, at the LOINC lookup — its own comment already said an unspecified reading codes as 2339-0, but the table had no entry to hit. The insights context breakdown names the bucket, and the Coach's glucose block now resolves through the shared helper rather than its own copy. Refs #943 --- .../glucose/glucose-advanced-disclosure.tsx | 2 ++ .../ai/coach/snapshot-blocks/glucose-block.ts | 17 +++++++--- .../measurements-charts-section.ts | 10 +++--- src/lib/doctor-report/collect.ts | 33 +++++++++++-------- src/lib/doctor-report/measurement-series.ts | 8 +++-- src/lib/fhir/loinc-map.ts | 4 +++ 6 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/components/insights/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx index 1e3ab6165..7fbe90613 100644 --- a/src/components/insights/glucose/glucose-advanced-disclosure.tsx +++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx @@ -31,6 +31,8 @@ const CONTEXT_LABEL_KEYS: Record = { "insights.bloodGlucose.clinical.advanced.byContext.postprandial", RANDOM: "insights.bloodGlucose.clinical.advanced.byContext.random", BEDTIME: "insights.bloodGlucose.clinical.advanced.byContext.bedtime", + // #943 — readings whose source recorded no meal-time context. + UNSPECIFIED: "insights.bloodGlucose.clinical.advanced.byContext.unspecified", }; export interface GlucoseAdvancedDisclosureProps { diff --git a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts index 7f0e855e1..cd8ac806f 100644 --- a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts @@ -12,7 +12,11 @@ * read plus the shared accumulators, so the emitted shape and ordering * are unchanged. */ -import { convertGlucose, type GlucoseUnit } from "@/lib/glucose"; +import { + convertGlucose, + glucoseContextBucket, + type GlucoseUnit, +} from "@/lib/glucose"; import { computeGlucoseClinicalMetrics, GLUCOSE_PANEL_WINDOW_DAYS, @@ -80,15 +84,18 @@ export function buildGlucoseBlock(ctx: Readonly): void { meta: { cluster: "glucose", source: "glucose" }, }); } else { - // Group by context (NULL → "unspecified"), then per-day mean. + // Group by context (NULL → "unspecified"), then per-day mean. The bucket + // resolver is the shared one (#943) — this block named the untagged + // bucket first, and the rest of the tree now resolves through the same + // helper rather than repeating the NULL arm at each surface. const byContext = new Map< string, Array<{ measuredAt: Date; value: number }> >(); for (const r of glucoseRows) { - const ctxKey = r.glucoseContext - ? String(r.glucoseContext).toLowerCase() - : "unspecified"; + const ctxKey = glucoseContextBucket( + typeof r.glucoseContext === "string" ? r.glucoseContext : null, + ).toLowerCase(); const list = byContext.get(ctxKey) ?? []; list.push({ measuredAt: r.measuredAt, value: r.value }); byContext.set(ctxKey, list); diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts index c13cfddca..fef72fd68 100644 --- a/src/lib/doctor-report-pdf/measurements-charts-section.ts +++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts @@ -1,4 +1,5 @@ import type { jsPDF } from "jspdf"; +import { GLUCOSE_CONTEXT_BUCKETS } from "@/lib/glucose"; import autoTable from "jspdf-autotable"; import { convertGlucose, resolveGlucoseUnit } from "../glucose"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -20,6 +21,8 @@ const GLUCOSE_LABEL_KEYS = { POSTPRANDIAL: "doctorReport.typeGlucosePostprandial", RANDOM: "doctorReport.typeGlucoseRandom", BEDTIME: "doctorReport.typeGlucoseBedtime", + // #943 — readings the source never tagged with a meal time. + UNSPECIFIED: "doctorReport.typeGlucoseUnspecified", } as const; const DEFAULT_GLUCOSE_RANGES = { @@ -27,13 +30,12 @@ const DEFAULT_GLUCOSE_RANGES = { POSTPRANDIAL: { min: 70, max: 140 }, RANDOM: { min: 70, max: 140 }, BEDTIME: { min: 90, max: 150 }, + // Same band as RANDOM: an untagged reading is a spot reading. + UNSPECIFIED: { min: 70, max: 140 }, } as const; const GLUCOSE_CONTEXTS: Array = [ - "FASTING", - "POSTPRANDIAL", - "RANDOM", - "BEDTIME", + ...GLUCOSE_CONTEXT_BUCKETS, ]; /** A single charted reading. */ diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 42ce9e3dc..e932224c8 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -20,12 +20,14 @@ import { getEffectiveRange, type ThresholdOverridesJson, } from "@/lib/analytics/effective-range"; -import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose"; +import { + GLUCOSE_CONTEXT_BUCKETS, + groupByGlucoseContext, + resolveGlucoseUnit, + thresholdMetricForContext, +} from "@/lib/glucose"; import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; -import type { - GlucoseContext, - MeasurementType, -} from "@/generated/prisma/client"; +import type { MeasurementType } from "@/generated/prisma/client"; import { reconstructSleepNights, type SleepStageRow, @@ -70,13 +72,6 @@ import { loadLabResults, } from "./clinical-records"; -const GLUCOSE_CONTEXTS: GlucoseContext[] = [ - "FASTING", - "POSTPRANDIAL", - "RANDOM", - "BEDTIME", -]; - const DENSE_REPORT_RAW_WINDOW_DAYS = 90; /** No medication row is read unless at least one medication leaf is admitted. */ @@ -413,9 +408,19 @@ export async function collectDoctorReportData( gender: userProfile?.gender ?? null, }; if (glucosePanelOn) { - for (const ctx of GLUCOSE_CONTEXTS) { + // #943 — the untagged bucket is a bucket. A report built from a meter + // that records no meal-time context used to carry an empty glucose panel. + const groupedGlucose = groupByGlucoseContext( + glucoseRows, + (m) => m.glucoseContext, + ); + const glucoseBuckets = denseGlucose + ? GLUCOSE_CONTEXT_BUCKETS + : groupedGlucose.map(([bucket]) => bucket); + for (const ctx of glucoseBuckets) { if (!denseGlucose) { - const rows = glucoseRows.filter((m) => m.glucoseContext === ctx); + const rows = + groupedGlucose.find(([bucket]) => bucket === ctx)?.[1] ?? []; if (rows.length === 0) continue; const values = rows.map((r) => r.value); glucoseStats[ctx] = { diff --git a/src/lib/doctor-report/measurement-series.ts b/src/lib/doctor-report/measurement-series.ts index 5fdb12474..f6d6e1d07 100644 --- a/src/lib/doctor-report/measurement-series.ts +++ b/src/lib/doctor-report/measurement-series.ts @@ -9,6 +9,7 @@ * Split out of the aggregator with the selection rework. */ import { collapseMeasurementsToCanonical } from "@/lib/doctor-report-helpers"; +import { glucoseContextBucket } from "@/lib/glucose"; import type { DoctorReportStats } from "@/lib/doctor-report-types"; import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; import type { DenseMeasurementBucket } from "./dense-buckets"; @@ -117,8 +118,11 @@ export function summariseDenseBuckets( } for (const row of canonical) { - if (row.type !== "BLOOD_GLUCOSE" || !row.glucoseContext) continue; - accumulate(glucoseStatsAcc, row.glucoseContext, { + if (row.type !== "BLOOD_GLUCOSE") continue; + // #943 — a reading with no meal-time context is filed under the shared + // untagged bucket. Skipping it here dropped the whole panel for an + // account whose meter never records one. + accumulate(glucoseStatsAcc, glucoseContextBucket(row.glucoseContext), { count: row.count, sum: row.sumValue, min: row.minValue, diff --git a/src/lib/fhir/loinc-map.ts b/src/lib/fhir/loinc-map.ts index 814ca0558..4920d04fa 100644 --- a/src/lib/fhir/loinc-map.ts +++ b/src/lib/fhir/loinc-map.ts @@ -77,6 +77,10 @@ export const GLUCOSE_LOINC: Record = }, RANDOM: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" }, BEDTIME: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" }, + // #943 — the untagged bucket. Already named in the table above; without + // the entry the exported bundle silently dropped every reading whose + // source recorded no meal time. + UNSPECIFIED: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" }, }; /* ── Clinical glucose-panel LOINCs (v1.18.0) ─────────────────────────── From ad62ab6f2794b2bac3afc6c94ce9f67096854905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:03:38 +0200 Subject: [PATCH 19/50] test: guard tile eligibility against a missing null arm The defect was invisible from the outside because every behavioural test in the suite seeded a tagged reading. Two checks close that: A structural guard that reads the dashboard surfaces and fails when a tile derives presence from a list of enum buckets with no null / unknown arm and no written reason, following one hop through a named constant. It carries its own positive and negative controls, so a refactor that silently stops matching anything fails here rather than going quiet. An integration test that seeds twenty readings with a genuinely NULL context against a real Postgres and reads the tile back through the same resolver the page renders with: eligible, latest value intact, and a tagged reading still filed under its own context. Refs #943 --- ...oard-tile-eligibility-bucket-guard.test.ts | 197 ++++++++++++++++++ .../dashboard-glucose-untagged.test.ts | 165 +++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts create mode 100644 tests/integration/dashboard-glucose-untagged.test.ts diff --git a/src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts b/src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts new file mode 100644 index 000000000..2fc4e3a07 --- /dev/null +++ b/src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts @@ -0,0 +1,197 @@ +/** + * Structural guard — a dashboard tile may not be gated on a closed enum + * whose column is nullable. + * + * The Blood Glucose tile decided whether to paint by filtering the four + * `GlucoseContext` members against a per-context summary record and asking + * whether any of them held readings. Every named context is a legal value of + * the column, so the list read as exhaustive — but `glucose_context` is + * nullable, and a meter synced through Apple Health writes no meal-time + * metadata at all. An account with one hundred percent untagged readings + * therefore matched no bucket, and the tile was omitted with the module on, + * the layout toggle on, and a reading from today. Nothing warned: every + * behavioural test in the suite seeded a tagged reading, so the missing arm + * was invisible from the outside (#943). + * + * What the guard asserts: wherever a dashboard tile surface derives presence + * from a list of enum buckets, the list must carry a null / unknown arm — or + * the call site must write down why it cannot. It matches on the list + * EXPRESSION, following one hop through a named constant, because the fix for + * this class is always the same: the untagged bucket joins the list the + * eligibility loop already walks. + * + * Its limits, written down so a later reader does not over-trust it: it sees + * presence tests spelled with `count`, and a bucket list that is either an + * inline literal or a single named constant. An eligibility gate that counted + * rows some other way, or assembled its bucket list at runtime, would slip + * past. The `detect()` self-controls below exist so a refactor that silently + * stops matching anything fails here rather than going quiet. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +/** + * A bucket list that carries one of these tokens has an arm for rows the + * enum cannot name. Matched against the list expression's source text, so a + * shared constant (`GLUCOSE_CONTEXT_BUCKETS = [...NAMED, UNSPECIFIED]`) + * satisfies it through its own name. + */ +const UNTAGGED_ARM = /UNSPECIFIED|UNKNOWN|UNTAGGED|OTHER|NONE/; + +/** The written-reason escape hatch, with a non-empty reason after it. */ +const WRITTEN_REASON = /tile-eligibility-exempt:\s*\S/; + +/** Dashboard surfaces that decide whether a tile paints. */ +const SURFACES = [ + "src/app/page-client.tsx", + ...listSourceFiles("src/components/dashboard"), + ...listSourceFiles("src/lib/dashboard"), +]; + +function listSourceFiles(dir: string): string[] { + const abs = join(process.cwd(), dir); + return readdirSync(abs) + .filter((name) => /\.tsx?$/.test(name)) + .map((name) => join(dir, name)) + .filter((rel) => statSync(join(process.cwd(), rel)).isFile()); +} + +interface Finding { + /** The bucket-list expression as written at the eligibility site. */ + list: string; + /** 1-based line of the eligibility site. */ + line: number; +} + +/** + * Iteration over a bucket list: `for (const x of LIST)` or + * `LIST.filter(…)` / `.some(…)`. The list is either an inline array literal + * or an identifier. + */ +const ITERATION = + /(?:for\s*\(\s*(?:const|let)\s+[^)]*?\bof\s+(\[[^\]]*\]|[A-Za-z_$][\w$]*)\s*\))|(?:(\[[^\]]*\]|[A-Za-z_$][\w$]*)\s*\.\s*(?:filter|some|flatMap)\s*\()/g; + +/** A presence test — "does this bucket hold any readings". */ +const PRESENCE = /\bcount\b/; + +/** How far past the iteration head a presence test still belongs to it. */ +const BODY_WINDOW = 500; + +/** + * Every eligibility site in `source` whose bucket list has no untagged arm + * and no written reason. `resolve` supplies the initializer text of a named + * constant so the guard can follow one hop out of the file. + */ +export function detect( + source: string, + resolve: (name: string) => string | null, +): Finding[] { + const findings: Finding[] = []; + ITERATION.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = ITERATION.exec(source)) !== null) { + const list = match[1] ?? match[2]; + if (!list) continue; + const body = source.slice(match.index, match.index + BODY_WINDOW); + if (!PRESENCE.test(body)) continue; + // Only enum-shaped lists are in scope: an inline literal of UPPER_SNAKE + // members, or an UPPER_SNAKE constant name. + const inline = list.startsWith("["); + const members = inline ? list.match(/"[A-Z][A-Z0-9_]*"/g) : null; + if (inline && (members?.length ?? 0) < 2) continue; + if (!inline && !/^[A-Z][A-Z0-9_]*$/.test(list)) continue; + const expression = inline ? list : (resolve(list) ?? list); + if (UNTAGGED_ARM.test(expression)) continue; + const before = source.slice(Math.max(0, match.index - 400), match.index); + if (WRITTEN_REASON.test(before)) continue; + findings.push({ + list, + line: source.slice(0, match.index).split("\n").length, + }); + } + return findings; +} + +/** Initializer text of `export const NAME = …` / `const NAME = …` in `src/`. */ +function resolveConstant(name: string): string | null { + for (const file of CONSTANT_SOURCES) { + const source = readFileSync(join(process.cwd(), file), "utf8"); + const declaration = new RegExp( + `(?:export\\s+)?const\\s+${name}\\s*(?::[^=]+)?=\\s*(\\[[\\s\\S]*?\\])`, + ).exec(source); + if (declaration) return declaration[1]; + } + return null; +} + +/** Files a dashboard surface may import a bucket list from. */ +const CONSTANT_SOURCES = ["src/lib/glucose.ts", ...SURFACES]; + +describe("detect() — the matcher itself", () => { + it("flags the shape that shipped: four enum members, no null arm", () => { + const shipped = ` + const contexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"]; + const present = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"].filter( + (ctx) => (byContext[ctx]?.count ?? 0) > 0, + ); + `; + const findings = detect(shipped, () => null); + expect(findings).toHaveLength(1); + expect(findings[0].list).toContain("FASTING"); + }); + + it("accepts a list that carries an untagged arm", () => { + const fixed = ` + for (const bucket of GLUCOSE_CONTEXT_BUCKETS) { + const summary = byContext?.[bucket]; + if (!summary || summary.count <= 0) continue; + } + `; + const resolve = () => '["FASTING", "BEDTIME", GLUCOSE_CONTEXT_UNSPECIFIED]'; + expect(detect(fixed, resolve)).toEqual([]); + // …and rejects the same site when the constant has no arm, so the pass + // above is the arm's doing and not the resolver's. + expect(detect(fixed, () => '["FASTING", "BEDTIME"]')).toHaveLength(1); + }); + + it("accepts a written reason", () => { + const exempt = ` + // tile-eligibility-exempt: sleep stages are never null on this path. + for (const stage of ["DEEP", "REM"]) { + if (byStage[stage].count > 0) return true; + } + `; + expect(detect(exempt, () => null)).toEqual([]); + }); + + it("ignores lists that are not presence tests", () => { + const unrelated = ` + const labels = ["WEIGHT", "PULSE"].map((type) => LABELS[type]); + `; + expect(detect(unrelated, () => null)).toEqual([]); + }); +}); + +describe("dashboard tile eligibility", () => { + it("scans the surfaces it claims to scan", () => { + // An empty match set must fail rather than read as clean: the guard is + // worthless the day its matchers stop finding the real call sites. + expect(SURFACES.length).toBeGreaterThan(5); + const sited = SURFACES.filter((file) => + PRESENCE.test(readFileSync(join(process.cwd(), file), "utf8")), + ); + expect(sited.length).toBeGreaterThan(0); + }); + + it("computes no tile's eligibility from an enum without a null arm", () => { + const offenders: string[] = []; + for (const file of SURFACES) { + const source = readFileSync(join(process.cwd(), file), "utf8"); + for (const finding of detect(source, resolveConstant)) { + offenders.push(`${file}:${finding.line} — ${finding.list}`); + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/tests/integration/dashboard-glucose-untagged.test.ts b/tests/integration/dashboard-glucose-untagged.test.ts new file mode 100644 index 000000000..810696cac --- /dev/null +++ b/tests/integration/dashboard-glucose-untagged.test.ts @@ -0,0 +1,165 @@ +/** + * #943 — untagged glucose readings light the dashboard tile. + * + * A reporter's meter syncs through Apple Health and never writes HealthKit's + * blood-glucose meal-time metadata, so all of their readings carry a NULL + * `glucose_context`. The tile strip filtered the four named contexts against + * the per-context summaries, matched none, and omitted the tile — module on, + * layout toggle on, a reading from today, and no warning anywhere. + * + * A real Postgres is what makes this convincing: the summaries are built from + * rows the database actually holds, with the column genuinely NULL, and the + * tile gate is the same pure resolver the page renders through. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +import { resolveGlucoseTiles } from "@/components/dashboard/dashboard-gates"; +import type { DataSummary } from "@/lib/analytics/trends"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +interface AnalyticsEnvelope { + data: { + glucoseByContext: Record | null; + }; + error: null; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +const READINGS = 20; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function seedUntaggedGlucose() { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "glucose-untagged", + email: "glucose-untagged@example.test", + role: "USER", + timezone: "Europe/Berlin", + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + // 20 readings, one per day, newest first at 104 mg/dL. Every one of them + // arrives exactly as the reporter's sync writes them: no meal-time context. + const now = Date.now(); + await prisma.measurement.createMany({ + data: Array.from({ length: READINGS }, (_, i) => ({ + userId: user.id, + type: "BLOOD_GLUCOSE" as const, + value: 104 + i, + unit: "mg/dL", + source: "APPLE_HEALTH" as const, + measuredAt: new Date(now - i * DAY_MS), + glucoseContext: null, + })), + }); + return user; +} + +async function readAnalytics(): Promise { + const { GET } = await import("@/app/api/analytics/route"); + const response = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics")); + expect(response.status).toBe(200); + return (await response.json()) as AnalyticsEnvelope; +} + +describe("dashboard glucose tile with untagged readings (#943)", () => { + it("stores the readings with a NULL context", async () => { + const user = await seedUntaggedGlucose(); + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: user.id, type: "BLOOD_GLUCOSE" }, + select: { glucoseContext: true }, + }); + + expect(rows).toHaveLength(READINGS); + expect(rows.every((row) => row.glucoseContext === null)).toBe(true); + }); + + it("reports the tile as eligible and carries the latest value", async () => { + await seedUntaggedGlucose(); + + const envelope = await readAnalytics(); + const byContext = envelope.data.glucoseByContext; + expect(byContext).not.toBeNull(); + + // The summaries carry the untagged bucket, and nothing else. + expect(Object.keys(byContext ?? {})).toEqual(["UNSPECIFIED"]); + const summary = byContext!.UNSPECIFIED; + expect(summary.count).toBe(READINGS); + expect(summary.latest).toBe(104); + expect(summary.avg30).not.toBeNull(); + + // …and the tile gate the dashboard renders through says "paint it". + const tiles = resolveGlucoseTiles(byContext ?? undefined); + expect(tiles).toHaveLength(1); + expect(tiles[0]).toMatchObject({ + bucket: "UNSPECIFIED", + labelKey: "targets.glucoseUnspecified", + }); + expect(tiles[0].summary.latest).toBe(104); + }); + + it("keeps a tagged reading in its own bucket beside the untagged ones", async () => { + const user = await seedUntaggedGlucose(); + await getPrismaClient().measurement.create({ + data: { + userId: user.id, + type: "BLOOD_GLUCOSE", + value: 92, + unit: "mg/dL", + source: "MANUAL", + measuredAt: new Date(), + glucoseContext: "FASTING", + }, + }); + + const envelope = await readAnalytics(); + const byContext = envelope.data.glucoseByContext ?? {}; + + expect(Object.keys(byContext).sort()).toEqual(["FASTING", "UNSPECIFIED"]); + expect(byContext.FASTING.count).toBe(1); + expect(byContext.UNSPECIFIED.count).toBe(READINGS); + expect(resolveGlucoseTiles(byContext).map((tile) => tile.bucket)).toEqual([ + "FASTING", + "UNSPECIFIED", + ]); + }); +}); From ae20ee3d9dd446e5562d4087c67f632c96e3de62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:16:50 +0200 Subject: [PATCH 20/50] Split the onboarding baseline inputs into their own component The step's profile fieldset moves to `baseline-fields.tsx` unchanged. The markup can now be rendered against a server answer on its own, which is where the next commit puts a test. --- src/components/onboarding/baseline-fields.tsx | 143 ++++++++++++++++++ src/components/onboarding/baseline-form.tsx | 102 +------------ 2 files changed, 149 insertions(+), 96 deletions(-) create mode 100644 src/components/onboarding/baseline-fields.tsx diff --git a/src/components/onboarding/baseline-fields.tsx b/src/components/onboarding/baseline-fields.tsx new file mode 100644 index 000000000..59ef5930a --- /dev/null +++ b/src/components/onboarding/baseline-fields.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { DateField } from "@/components/ui/date-field"; +import { FieldGroup } from "@/components/ui/field-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { HeightFieldControl } from "@/components/profile/height-field-control"; +import { useTranslations } from "@/lib/i18n/context"; +import type { + HeightDraft, + HeightUnitAdapter, +} from "@/lib/profile/height-unit-display"; + +/** + * The four profile inputs of the onboarding baseline step. + * + * Split out of `baseline-form.tsx` so the markup can be rendered on its + * own against a server answer. The step's whole defect lived between + * the response and these inputs — the server named a field it refused + * and nothing on screen changed — so the seam a test needs is exactly + * here: field values in, markup out. + */ + +export interface BaselineFieldValues { + displayName: string; + height: HeightDraft; + dateOfBirth: string; + gender: string; +} + +const IDS = { + displayName: "ob-baseline-display-name", + heightCm: "ob-baseline-height", + dateOfBirth: "ob-baseline-dob", + gender: "ob-baseline-gender", +} as const; + +export function BaselineFields({ + value, + onChange, + heightAdapter, +}: { + value: BaselineFieldValues; + onChange: ( + key: K, + next: BaselineFieldValues[K], + ) => void; + heightAdapter: HeightUnitAdapter; +}) { + const { t } = useTranslations(); + + return ( +
+ {t("onboarding.baseline.title")} + + + onChange("displayName", e.target.value)} + autoComplete="nickname" + maxLength={50} + placeholder={t("onboarding.baseline.displayNamePlaceholder")} + /> + + +
+ + onChange("height", next)} + autoComplete="off" + /> + + + + +
+ + + onChange("dateOfBirth", next)} + max={new Date().toISOString().slice(0, 10)} + autoComplete="bday" + /> + +
+ ); +} diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index 7576be392..8d81811dc 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -8,17 +8,7 @@ import { toast } from "sonner"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { DateField } from "@/components/ui/date-field"; -import { FieldGroup } from "@/components/ui/field-group"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { HeightFieldControl } from "@/components/profile/height-field-control"; +import { BaselineFields } from "@/components/onboarding/baseline-fields"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { EMPTY_HEIGHT_DRAFT, @@ -201,91 +191,11 @@ export function BaselineForm() {

-
- {t("onboarding.baseline.title")} - - - patch("displayName", e.target.value)} - autoComplete="nickname" - maxLength={50} - placeholder={t("onboarding.baseline.displayNamePlaceholder")} - /> - - -
- - patch("height", next)} - autoComplete="off" - /> - - - - -
- - - patch("dateOfBirth", value)} - max={new Date().toISOString().slice(0, 10)} - autoComplete="bday" - /> - -
+ Date: Wed, 9 Sep 2026 23:21:21 +0200 Subject: [PATCH 21/50] Show a refused profile field under the input it belongs to The onboarding baseline step flashed a toast naming one refused field and walked on to the done screen, stamping the account as set up over a value the server never stored. The value the person typed was gone with the step. A refused field now writes its reason into that field's own error slot, marks the control invalid, and holds the step until the person corrects it. Every refused field is named, not only the first, and the reason comes from the validator code rather than the validator's own prose, in all seven shipped languages. --- messages/de.json | 8 + messages/en.json | 8 + messages/es.json | 8 + messages/fr.json | 8 + messages/it.json | 8 + messages/ko.json | 8 + messages/pl.json | 8 + src/components/forms/field-error.tsx | 32 ++++ .../baseline-fields-rejection-slots.test.tsx | 175 ++++++++++++++++++ .../baseline-form-rejected-fields.test.ts | 19 +- src/components/onboarding/baseline-fields.tsx | 36 +++- .../onboarding/baseline-form-utils.ts | 47 +++-- src/components/onboarding/baseline-form.tsx | 31 +++- .../profile/height-field-control.tsx | 12 ++ src/components/ui/field-group.tsx | 7 + src/lib/profile/rejected-fields.ts | 49 +++++ 16 files changed, 438 insertions(+), 26 deletions(-) create mode 100644 src/components/forms/field-error.tsx create mode 100644 src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx diff --git a/messages/de.json b/messages/de.json index eef3089fe..2c77763c1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5353,6 +5353,14 @@ "profileSaved": "Profil gespeichert", "profilePartiallySaved": "Profil gespeichert, aber {field} konnte nicht aktualisiert werden.", "profileNothingSaved": "Es wurde nichts gespeichert. Bitte {field} korrigieren und erneut versuchen.", + "profileRejection": { + "tooBig": "Der Wert liegt über dem Bereich, den dieses Feld annimmt.", + "tooSmall": "Der Wert liegt unter dem Bereich, den dieses Feld annimmt.", + "invalidValue": "Das ist keine der Antworten, die dieses Feld annimmt.", + "invalidFormat": "Das ist nicht in einer Form geschrieben, die dieses Feld lesen kann.", + "invalidType": "Der Wert kam nicht in einer Form an, die dieses Feld speichern kann.", + "other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen." + }, "avatar": { "title": "Profilfoto", "description": "Lade ein Foto hoch, das auf deinem eigenen Server gespeichert wird. Es erscheint überall in der App anstelle eines externen Avatar-Dienstes.", diff --git a/messages/en.json b/messages/en.json index e75e342a8..f4d924b7e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5353,6 +5353,14 @@ "profileSaved": "Profile saved", "profilePartiallySaved": "Profile saved, but {field} could not be updated.", "profileNothingSaved": "Nothing was saved. Fix {field} and try again.", + "profileRejection": { + "tooBig": "That value is above the range this field accepts.", + "tooSmall": "That value is below the range this field accepts.", + "invalidValue": "That is not one of the answers this field accepts.", + "invalidFormat": "That is not written in a form this field can read.", + "invalidType": "That value did not arrive in a form this field can store.", + "other": "This value was not accepted. Check it and try again." + }, "avatar": { "title": "Profile photo", "description": "Upload a photo stored on your own server. It appears across the app in place of an external avatar service.", diff --git a/messages/es.json b/messages/es.json index 91f6187d8..da24ec7e2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5353,6 +5353,14 @@ "profileSaved": "Perfil guardado", "profilePartiallySaved": "Perfil guardado, pero no se pudo actualizar {field}.", "profileNothingSaved": "No se guardó nada. Corrige {field} e inténtalo de nuevo.", + "profileRejection": { + "tooBig": "El valor está por encima del rango que acepta este campo.", + "tooSmall": "El valor está por debajo del rango que acepta este campo.", + "invalidValue": "Esa no es una de las respuestas que acepta este campo.", + "invalidFormat": "Eso no está escrito en un formato que este campo pueda leer.", + "invalidType": "El valor no llegó en un formato que este campo pueda guardar.", + "other": "No se aceptó este valor. Revísalo e inténtalo de nuevo." + }, "avatar": { "title": "Foto de perfil", "description": "Sube una foto que se almacena en tu propio servidor. Aparece en toda la aplicación en lugar de un servicio de avatares externo.", diff --git a/messages/fr.json b/messages/fr.json index 23921c63d..f3f88618f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5353,6 +5353,14 @@ "profileSaved": "Profil enregistré", "profilePartiallySaved": "Profil enregistré, mais {field} n'a pas pu être mis à jour.", "profileNothingSaved": "Rien n'a été enregistré. Corrigez {field} et réessayez.", + "profileRejection": { + "tooBig": "La valeur dépasse la plage acceptée par ce champ.", + "tooSmall": "La valeur est en dessous de la plage acceptée par ce champ.", + "invalidValue": "Ce n'est pas une des réponses acceptées par ce champ.", + "invalidFormat": "Ce n'est pas écrit dans un format que ce champ peut lire.", + "invalidType": "La valeur n'est pas arrivée dans un format que ce champ peut enregistrer.", + "other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez." + }, "avatar": { "title": "Photo de profil", "description": "Téléverse une photo stockée sur ton propre serveur. Elle apparaît dans toute l'application à la place d'un service d'avatar externe.", diff --git a/messages/it.json b/messages/it.json index e17e31091..a805995dc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5353,6 +5353,14 @@ "profileSaved": "Profilo salvato", "profilePartiallySaved": "Profilo salvato, ma non è stato possibile aggiornare {field}.", "profileNothingSaved": "Non è stato salvato nulla. Correggi {field} e riprova.", + "profileRejection": { + "tooBig": "Il valore supera l'intervallo accettato da questo campo.", + "tooSmall": "Il valore è inferiore all'intervallo accettato da questo campo.", + "invalidValue": "Questa non è una delle risposte accettate da questo campo.", + "invalidFormat": "Non è scritto in un formato che questo campo possa leggere.", + "invalidType": "Il valore non è arrivato in un formato che questo campo possa salvare.", + "other": "Questo valore non è stato accettato. Controllalo e riprova." + }, "avatar": { "title": "Foto del profilo", "description": "Carica una foto archiviata sul tuo server. Appare in tutta l'app al posto di un servizio di avatar esterno.", diff --git a/messages/ko.json b/messages/ko.json index 66d228404..c382284b8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5353,6 +5353,14 @@ "profileSaved": "프로필을 저장했어요", "profilePartiallySaved": "프로필을 저장했지만 {field} 항목은 업데이트하지 못했어요.", "profileNothingSaved": "아무것도 저장하지 못했어요. {field} 항목을 고친 뒤 다시 시도해 주세요.", + "profileRejection": { + "tooBig": "이 항목이 받는 범위보다 큰 값이에요.", + "tooSmall": "이 항목이 받는 범위보다 작은 값이에요.", + "invalidValue": "이 항목이 받는 답변이 아니에요.", + "invalidFormat": "이 항목이 읽을 수 있는 형식이 아니에요.", + "invalidType": "이 항목이 저장할 수 있는 형식으로 오지 않았어요.", + "other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요." + }, "avatar": { "title": "프로필 사진", "description": "직접 운영하는 서버에 저장되는 사진을 올려요. 외부 아바타 서비스 대신 앱 전체에 표시돼요.", diff --git a/messages/pl.json b/messages/pl.json index 7ec4d5cbc..b05aea094 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5353,6 +5353,14 @@ "profileSaved": "Profil zapisany", "profilePartiallySaved": "Profil zapisany, ale nie udało się zaktualizować pola {field}.", "profileNothingSaved": "Nic nie zostało zapisane. Popraw {field} i spróbuj ponownie.", + "profileRejection": { + "tooBig": "Wartość jest powyżej zakresu, który przyjmuje to pole.", + "tooSmall": "Wartość jest poniżej zakresu, który przyjmuje to pole.", + "invalidValue": "To nie jest jedna z odpowiedzi, które przyjmuje to pole.", + "invalidFormat": "To nie jest zapisane w formacie, który to pole może odczytać.", + "invalidType": "Wartość nie dotarła w formacie, który to pole może zapisać.", + "other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie." + }, "avatar": { "title": "Zdjęcie profilowe", "description": "Prześlij zdjęcie przechowywane na własnym serwerze. Pojawia się w całej aplikacji zamiast zewnętrznej usługi awatarów.", diff --git a/src/components/forms/field-error.tsx b/src/components/forms/field-error.tsx new file mode 100644 index 000000000..5a720a786 --- /dev/null +++ b/src/components/forms/field-error.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; + +/** + * The error slot that belongs to one input. + * + * Every form that writes the profile shows a refused field the same + * way, or the same rejection reads differently depending on where the + * person happened to be standing. This is that one way: a destructive + * sentence directly under the control, carrying an id the control + * points `aria-describedby` at, so the reason is announced with the + * field rather than only painted next to it. + * + * `role="alert"` is deliberate. The sentence appears in response to a + * save the person just asked for, and a person who has already moved + * focus past the field would otherwise never learn the value did not + * land. + */ +export function FieldError({ + id, + message, +}: { + /** Conventionally `-error`. */ + id: string; + message?: ReactNode; +}) { + if (!message) return null; + return ( + + ); +} diff --git a/src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx b/src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx new file mode 100644 index 000000000..57aec24f9 --- /dev/null +++ b/src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx @@ -0,0 +1,175 @@ +/** + * A rejection the server names has to reach the input it belongs to. + * + * `PUT /api/auth/profile` writes field by field and reports the fields + * it refused. The onboarding step read that report only far enough to + * flash a toast and then walked the person to the next screen, so an + * out-of-range height was gone by the time anyone could act on it and + * the wizard counted the account as set up regardless. + * + * This runs the whole strip — build the body, PUT it over a stubbed + * `fetch`, read the answer, render the real inputs with what came back + * — because the gap was in the join, not in either end. The English + * bundle is the shipped one, so a sentence that reads as a key or + * leaks validator prose fails here. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { getCachedMessages } from "@/lib/i18n/load-locale"; +import { resolveKey } from "@/lib/i18n/resolve-key"; +import { + EMPTY_HEIGHT_DRAFT, + resolveHeightUnitAdapter, +} from "@/lib/profile/height-unit-display"; +import { BaselineFields } from "../baseline-fields"; +import { + baselineFieldLabelKeys, + buildBaselineProfileBody, + describeBaselineSaveOutcome, + putBaselineProfile, +} from "../baseline-form-utils"; + +/** A `t` backed by the shipped bundle — no stand-in strings. */ +function translator(locale: "en" | "de") { + const bundle = getCachedMessages(locale); + if (!bundle) throw new Error(`No cached bundle for ${locale}`); + return (key: string, params?: Record) => { + const raw = resolveKey(bundle as Record, key); + if (raw === undefined) return key; + return Object.entries(params ?? {}).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + raw, + ); + }; +} + +const FILLED_FORM = { + displayName: "Robin", + dateOfBirth: "1988-04-02", + gender: "OTHER", +}; + +function stubProfilePut(status: number, body: unknown) { + vi.stubGlobal("fetch", () => + Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ), + ); +} + +/** Render the step's inputs exactly as the step renders them. */ +function renderFields(errors: Record) { + return renderToStaticMarkup( + + + , + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("a refused baseline field reaches its own input", () => { + it("paints the reason in the height field's slot and holds the step", async () => { + stubProfilePut(200, { + data: { + id: "u1", + displayName: "Robin", + rejectedFields: [{ path: "heightCm", code: "too_big" }], + }, + error: null, + }); + + const en = translator("en"); + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 940)), + en, + baselineFieldLabelKeys(false), + ); + + // A field was refused: the wizard stays put and does not stamp the + // account as finished behind the person's back. + expect(outcome.advance).toBe(false); + expect(outcome.fieldErrors).toEqual({ + heightCm: en("settings.profileRejection.tooBig"), + }); + + // What the outcome produced is what the input renders — no second + // mapping in between for a rename to fall through. + const html = renderFields(outcome.fieldErrors); + expect(html).toContain(en("settings.profileRejection.tooBig")); + // Neither a bare key nor the validator's own vocabulary. + expect(html).not.toContain("settings.profileRejection"); + expect(html).not.toContain("too_big"); + // Announced with the input, not merely painted beside it. + expect(html).toContain('id="ob-baseline-height-error"'); + expect(html).toContain('aria-describedby="ob-baseline-height-error"'); + expect(html).toContain('aria-invalid="true"'); + }); + + it("leaves every accepted field's slot empty", async () => { + stubProfilePut(200, { data: { id: "u1" }, error: null }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)), + translator("en"), + baselineFieldLabelKeys(false), + ); + + expect(outcome.advance).toBe(true); + expect(outcome.fieldErrors).toEqual({}); + // (`aria-invalid:` also appears inside the primitives' Tailwind + // class strings, so the assertion is on the rendered attribute.) + const html = renderFields({}); + expect(html).not.toContain('aria-invalid="true"'); + expect(html).not.toContain('role="alert"'); + }); + + it("names every refused field, not only the first", async () => { + stubProfilePut(422, { + data: null, + error: 'Nothing was saved. Fix the "dateOfBirth" field and try again.', + meta: { errorCode: "profile.update.nothingSaved" }, + details: { + issues: [ + { path: "dateOfBirth", code: "invalid_value" }, + { path: "gender", code: "invalid_value" }, + ], + }, + }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, null)), + translator("de"), + baselineFieldLabelKeys(false), + ); + + expect(outcome.advance).toBe(false); + expect(Object.keys(outcome.fieldErrors).sort()).toEqual([ + "dateOfBirth", + "gender", + ]); + // The person's language, not the validator's. + const de = translator("de"); + expect(outcome.fieldErrors.gender).toBe( + de("settings.profileRejection.invalidValue"), + ); + expect(outcome.fieldErrors.gender).not.toContain("invalid_value"); + }); +}); diff --git a/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts b/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts index 198ed664f..8094ddbe0 100644 --- a/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts +++ b/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts @@ -60,7 +60,7 @@ afterEach(() => { }); describe("onboarding baseline profile save", () => { - it("names the rejected field and still lets the accepted ones stand", async () => { + it("names the rejected field, keeps the accepted ones, and holds the step", async () => { const calls = stubProfilePut(200, { data: { id: "u1", @@ -88,14 +88,21 @@ describe("onboarding baseline profile save", () => { gender: "OTHER", }); - // The accepted fields are saved and the wizard moves on... - expect(outcome.advance).toBe(true); - // ...but the declined one is said out loud, by the name this screen - // put next to the input. + // The accepted fields are saved, and the declined one is said out + // loud by the name this screen put next to the input. expect(outcome.notice).toEqual({ tone: "warning", message: "Profile saved, but Height (cm) could not be updated.", }); + // This assertion was `true` until the rejection reached the input + // it belongs to. Advancing here posted `step: 4`, stamped the + // account as set up and replaced the only screen that could show + // the refused value — a toast on the way out was the whole notice + // the person got. The step now waits for a correction. + expect(outcome.advance).toBe(false); + expect(outcome.fieldErrors.heightCm).toBe( + "That value is above the range this field accepts.", + ); }); it("says nothing when every field was accepted", async () => { @@ -107,7 +114,7 @@ describe("onboarding baseline profile save", () => { baselineFieldLabelKeys(false), ); - expect(outcome).toEqual({ advance: true, notice: null }); + expect(outcome).toEqual({ advance: true, notice: null, fieldErrors: {} }); }); it("names the blocking field and holds the step when nothing was saved", async () => { diff --git a/src/components/onboarding/baseline-fields.tsx b/src/components/onboarding/baseline-fields.tsx index 59ef5930a..5eaf113e5 100644 --- a/src/components/onboarding/baseline-fields.tsx +++ b/src/components/onboarding/baseline-fields.tsx @@ -24,7 +24,12 @@ import type { * own against a server answer. The step's whole defect lived between * the response and these inputs — the server named a field it refused * and nothing on screen changed — so the seam a test needs is exactly - * here: field values in, markup out. + * here: field values in, field rejections in, markup out. + * + * Each field id doubles as the id of its error paragraph + * (`-error`), which is what the control points `aria-describedby` + * at, so a rejection is announced with the input rather than only + * painted next to it. */ export interface BaselineFieldValues { @@ -34,6 +39,15 @@ export interface BaselineFieldValues { gender: string; } +/** + * Per-field sentences keyed by the SCHEMA field name the server uses + * (`heightCm`, not `height`) — the key the rejection arrives under, so + * nothing has to be renamed between the wire and the slot. + */ +export type BaselineFieldErrors = Partial< + Record<"displayName" | "heightCm" | "dateOfBirth" | "gender", string> +>; + const IDS = { displayName: "ob-baseline-display-name", heightCm: "ob-baseline-height", @@ -45,6 +59,7 @@ export function BaselineFields({ value, onChange, heightAdapter, + errors, }: { value: BaselineFieldValues; onChange: ( @@ -52,6 +67,7 @@ export function BaselineFields({ next: BaselineFieldValues[K], ) => void; heightAdapter: HeightUnitAdapter; + errors: BaselineFieldErrors; }) { const { t } = useTranslations(); @@ -63,6 +79,7 @@ export function BaselineFields({ htmlFor={IDS.displayName} label={t("onboarding.baseline.displayNameLabel")} hint={t("onboarding.baseline.displayNameHint")} + error={errors.displayName} > @@ -82,6 +103,7 @@ export function BaselineFields({ ? t("onboarding.baseline.heightLabelFtIn") : t("onboarding.baseline.heightLabel") } + error={errors.heightCm} > onChange("height", next)} autoComplete="off" + invalid={Boolean(errors.heightCm)} + describedBy={errors.heightCm ? `${IDS.heightCm}-error` : undefined} /> setFullName(e.target.value)} + onChange={(e) => { + setFullName(e.target.value); + clearFieldError("fullName"); + }} placeholder={t("settings.identity.fullNamePlaceholder")} maxLength={120} autoComplete="name" @@ -496,7 +521,10 @@ export function AccountSection() { setInsurerName(e.target.value)} + onChange={(e) => { + setInsurerName(e.target.value); + clearFieldError("insurerName"); + }} placeholder={t("settings.identity.insurerPlaceholder")} maxLength={120} aria-invalid={fieldErrors.insurerName ? true : undefined} @@ -516,9 +544,10 @@ export function AccountSection() { - setInsuranceNumber(e.target.value.toUpperCase()) - } + onChange={(e) => { + setInsuranceNumber(e.target.value.toUpperCase()); + clearFieldError("insuranceNumber"); + }} placeholder="A123456780" maxLength={10} autoCapitalize="characters" From 91ae90836bec3555898072fe0d015d54f6519661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 00:47:16 +0200 Subject: [PATCH 33/50] docs(env): document the two optional off-host backup variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BACKUP_S3_REGION and BACKUP_RETENTION_DAYS are both on the compose whitelist and both read by the backup job, but the example file listed neither — so an operator following it had no way to learn that the region defaults to auto, or that the retention number is what the bucket's own lifecycle rule has to be set to, since the worker never deletes an object itself. --- .env.production.example | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.env.production.example b/.env.production.example index ee3d19cf4..4a56a53b6 100644 --- a/.env.production.example +++ b/.env.production.example @@ -306,6 +306,20 @@ API_TOKEN_HMAC_KEY="" # BACKUP_S3_SECRET_KEY="" # BACKUP_ENCRYPTION_KEY="" +# The two optional members of the set, both with defaults, so leaving them +# unset does not disable backups. +# +# Region sent with the SigV4 signature. Default "auto", which is what +# Cloudflare R2 expects; AWS S3 and most MinIO deployments want their real +# region name here. +# BACKUP_S3_REGION="auto" +# +# How long backup objects are meant to live, in days. Default 30, minimum 1. +# The worker never deletes an object -- its grant is PutObject, GetObject and +# AbortMultipartUpload -- so this number is what you set the bucket's own +# lifecycle rule to. See docs/ops/backup-restore.md. +# BACKUP_RETENTION_DAYS="30" + # ----------------------------------------------------------------------------- # Audit-trail retention -- optional From 8ad5b90f881aededd65f3f9affdb1a67c4b7f83b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 00:48:31 +0200 Subject: [PATCH 34/50] docs(ops): the off-host worker never prunes; the bucket lifecycle does The runbook said the worker prunes objects older than BACKUP_RETENTION_DAYS. It never calls DeleteObject; its grant is PutObject, GetObject and AbortMultipartUpload, on purpose, so a compromised worker cannot wipe the history. The sentence now says what expires objects and what the variable is for. --- docs/ops/backup-restore.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 92991852b..0ac2d7ad5 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -54,8 +54,10 @@ Cloudflare R2 the **Object Read & Write** token already covers all three. ## Bucket lifecycle (recommended) -The worker prunes objects older than `BACKUP_RETENTION_DAYS`, but the -storage provider's lifecycle rule is the canonical safety net: +The worker never deletes a backup object: its grant covers PutObject, +GetObject and AbortMultipartUpload only, so a compromised worker cannot +wipe the history. Expiry is the storage provider's lifecycle rule, set to +match `BACKUP_RETENTION_DAYS`: ``` Filter: "" (all objects) From 2c740f04ac3ada1cb9dc9ac1887e6ddcba2b20d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 00:58:12 +0200 Subject: [PATCH 35/50] test(e2e): cover the Apple Health export import end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upload a synthetic export.zip on the import card, wait out the queued job through the card's own status poll, and read the imported readings back off the measurements list with the units the parser resolved. Weight, the blood-pressure pair and the step count are asserted; the walking-distance case is written and skipped, naming issue #944 — a record written in km lands as metres today, and the assertion is kept at full strength so the fix has something to turn green. The refusing control uploads a valid ZIP with no export.xml: the card surfaces the extractor's own refusal and the account's Apple Health row count does not move. Measurement rows now carry data-measurement-type / -value / -unit beside the rendered cell, on both the desktop table and the mobile list, so an assertion binds to the reading rather than to whatever the locale formatter made of it. The import card's error line gets a test id for the same reason. --- e2e/README.md | 1 + e2e/apple-health-import.spec.ts | 197 ++++++++++++++++++ e2e/fixtures/apple-health-export.ts | 188 +++++++++++++++++ playwright.config.ts | 9 + .../measurements/measurement-list.tsx | 25 +++ .../import-panel/apple-health-import-card.tsx | 1 + 6 files changed, 421 insertions(+) create mode 100644 e2e/apple-health-import.spec.ts create mode 100644 e2e/fixtures/apple-health-export.ts diff --git a/e2e/README.md b/e2e/README.md index 4cf2a7ef8..202dd3dea 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -14,6 +14,7 @@ The Playwright config (`../playwright.config.ts`) builds the app (`next build`) - **`setup/`** — `test.ts` (the suite's `test` + `expect` entry point), `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers). - **`utils/`** — fixtures such as `mock-dashboard-snapshot.ts`. +- **`fixtures/`** — synthetic payloads a journey uploads, built in code rather than committed as blobs so the content stays reviewable (`apple-health-export.ts`). - **`*.spec.ts`** — one spec per flow: auth/login/redirect, dashboard, charts, the medication-wizard cadences (`medications-wizard-*`), insights generation + scroll restoration, doctor report, settings, mobile-viewport consistency, locale switch, accessibility (`a11y.spec.ts`), and the `v1427-*` regression set. ## Conventions diff --git a/e2e/apple-health-import.spec.ts b/e2e/apple-health-import.spec.ts new file mode 100644 index 000000000..0ef9d061e --- /dev/null +++ b/e2e/apple-health-import.spec.ts @@ -0,0 +1,197 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + appleHealthExportZip, + archiveWithoutExportXml, + FIXTURE_READINGS, + MISSING_EXPORT_XML_REASON, +} from "./fixtures/apple-health-export"; + +/** + * Apple Health `export.zip` → readings on the measurements list. + * + * The one journey that crosses every layer the import owns: a person drops + * an archive on `/settings/export`, the kick-off route streams it to disk and + * queues `apple-health-import-v2`, the pg-boss worker in the same process + * unpacks it and streams the XML into `Measurement` rows, the card's status + * poll turns the spinner into a written outcome, and the rows show up on + * `/measurements` carrying the units the parser resolved. Every one of those + * steps has unit coverage; none of it proves the chain holds when a real + * browser drives it against a real queue and a real database. + * + * The archives are synthetic and built in memory — see + * `fixtures/apple-health-export.ts`. + */ +type FixtureReading = (typeof FIXTURE_READINGS)[keyof typeof FIXTURE_READINGS]; + +/** + * Serial, because the journey mutates the one shared account and the refusal + * control reads a count off it. Two of these tests in flight at once would + * also spend the kick-off route's three-uploads-per-minute budget on each + * other rather than on what they are testing. + */ +test.describe.configure({ mode: "serial" }); + +/** + * The import is the slowest single action in the browser suite: an upload, + * a queue hop, an unpack, a parse, and a status poll that ticks every two + * seconds. The default 30 s budget covers none of that on a loaded runner. + */ +test.setTimeout(180_000); + +/** + * Clear this account's upload bucket before each upload. + * + * `POST /api/import/apple-health-export` allows three uploads per user per + * minute, which is the right production number and exactly one too few for a + * spec that uploads twice and is expected to survive `--repeat-each`. The + * fourth upload inside the window would come back 429 and the card would + * render the rate-limit copy — a red run that says nothing about the import. + * + * Reaching for Postgres directly is the same channel `global-setup.ts` uses + * to seed and to clear the auth buckets; the alternative is to sit out a + * 60-second window, which is a sleep wearing a poll's clothing. + */ +async function clearImportUploadBucket(): Promise { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error( + "DATABASE_URL is unset — the import journey needs the same database the app is running against.", + ); + } + const { default: pg } = await import("pg"); + const client = new pg.Client({ connectionString }); + await client.connect(); + try { + await client.query( + `DELETE FROM rate_limits WHERE key LIKE 'import:apple-health:%'`, + ); + } finally { + await client.end(); + } +} + +/** Open the import surface and hand back the Apple Health card. */ +async function openImportCard(page: Page) { + await page.goto("/settings/export", { waitUntil: "domcontentloaded" }); + const card = page.getByTestId("import-card-apple-health"); + await expect(card).toBeVisible({ timeout: 30_000 }); + return card; +} + +/** + * Hand the archive to the card's own file input, which is what a drop or a + * click on "Choose file" ends up doing. The upload itself then runs through + * the card's `fetch`, so the spec seeds nothing the page would not have sent. + */ +async function uploadArchive( + page: Page, + archive: Buffer, + name: string, +): Promise { + const card = await openImportCard(page); + await card.locator('input[type="file"]').setInputFiles({ + name, + mimeType: "application/zip", + buffer: archive, + }); +} + +/** How many readings this account holds from an Apple Health import. */ +async function appleHealthRowCount(page: Page): Promise { + return page.evaluate(async () => { + const res = await fetch( + "/api/measurements?sourceEq=APPLE_HEALTH&limit=1&offset=0", + { credentials: "include" }, + ); + const body = (await res.json()) as { + data: { meta: { total: number } } | null; + }; + if (!body.data) throw new Error("measurements list returned no data"); + return body.data.meta.total; + }); +} + +/** + * Assert one imported reading on the measurements list, by the value and the + * unit the row carries rather than by the locale text it renders. + */ +async function expectReadingOnList( + page: Page, + reading: FixtureReading, +): Promise { + await page.goto(`/measurements?type=${reading.type}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.locator('[data-slot="measurement-rows"]')).toBeVisible({ + timeout: 30_000, + }); + const row = page.locator( + `[data-testid="measurement-row"][data-measurement-type="${reading.type}"]` + + `[data-measurement-value="${reading.value}"]`, + ); + await expect(row).toHaveCount(1); + await expect(row).toHaveAttribute("data-measurement-unit", reading.unit); +} + +test.describe("Apple Health export import", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("an uploaded export surfaces as readings with their units", async ({ + page, + }) => { + await clearImportUploadBucket(); + await uploadArchive(page, appleHealthExportZip(), "export.zip"); + + // The card polls the status route every two seconds and swaps the + // spinner for the written outcome on a terminal state. `data-outcome` + // is the outcome itself; the sentence beside it is translated copy. + const result = page.getByTestId("import-apple-health-result"); + await expect(result).toBeVisible({ timeout: 150_000 }); + await expect(result).toHaveAttribute("data-outcome", "success"); + + // The weight the archive wrote in kilograms, the blood-pressure pair it + // wrote in mmHg, and the step count it wrote as a bare count — each on + // the list, each carrying the unit the parser resolved for it. + await expectReadingOnList(page, FIXTURE_READINGS.weight); + await expectReadingOnList(page, FIXTURE_READINGS.systolic); + await expectReadingOnList(page, FIXTURE_READINGS.diastolic); + await expectReadingOnList(page, FIXTURE_READINGS.steps); + }); + + test("a distance written in kilometres is stored in metres", async ({ + page, + }) => { + test.skip( + true, + "issue #944 — the parser resolves a record's unit from the type map " + + "and ignores the `unit` attribute the record carries, so a distance " + + "written as 2.484 km lands as 2.484 m. The fix is on a branch that " + + "has not reached main; this is the contract it has to meet, kept " + + "here at full strength rather than relaxed to match today's value.", + ); + await expectReadingOnList(page, FIXTURE_READINGS.walkingDistance); + }); + + test("an archive without export.xml is refused and imports nothing", async ({ + page, + }) => { + await clearImportUploadBucket(); + await openImportCard(page); + const before = await appleHealthRowCount(page); + + await uploadArchive(page, archiveWithoutExportXml(), "not-an-export.zip"); + + // The worker's own words, surfaced verbatim by the card: the failure + // reason is honest English free text, not a code the UI translates. + const error = page.getByTestId("import-apple-health-error"); + await expect(error).toBeVisible({ timeout: 150_000 }); + await expect(error).toContainText(MISSING_EXPORT_XML_REASON); + + // And a refusal that still wrote something would be worse than no + // refusal at all. + expect(await appleHealthRowCount(page)).toBe(before); + }); +}); diff --git a/e2e/fixtures/apple-health-export.ts b/e2e/fixtures/apple-health-export.ts new file mode 100644 index 000000000..cb02484eb --- /dev/null +++ b/e2e/fixtures/apple-health-export.ts @@ -0,0 +1,188 @@ +/** + * Synthetic Apple Health `export.zip` fixtures for the import journey. + * + * The archives are built in memory rather than committed as binaries, for + * two reasons. The XML stays readable in the diff — a fixture whose content + * only exists inside a blob is a fixture nobody reviews — and the bytes stay + * deterministic, which the kick-off route's content-hash idempotency needs: + * a re-run of the same spec (`--repeat-each`) must resolve to the job the + * first run created instead of queueing a second import of the same export. + * + * The data is invented. No real reading, account, device or name appears + * here; every source is a fixture label and every timestamp is a fixed day + * in the past chosen so it cannot collide with anything the rest of the + * suite writes. + */ +import { crc32, deflateRawSync } from "node:zlib"; + +/** The calendar day (Europe/Berlin) every fixture record is stamped on. */ +export const FIXTURE_DAY = "2019-03-04"; + +/** + * What the archive claims, and what the measurements list must show for it. + * + * `value` is the number the row carries once the import has run, in the + * canonical DB unit — which is NOT always the unit the record was written + * in. That gap is the point of the walking-distance entry below. + */ +export const FIXTURE_READINGS = { + weight: { type: "WEIGHT", value: 74.2, unit: "kg" }, + systolic: { type: "BLOOD_PRESSURE_SYS", value: 118, unit: "mmHg" }, + diastolic: { type: "BLOOD_PRESSURE_DIA", value: 76, unit: "mmHg" }, + steps: { type: "ACTIVITY_STEPS", value: 8421, unit: "steps" }, + /** + * Written as `unit="km"`, stored in metres: 2.484 km is 2484 m. Issue #944 + * — the parser reads the mapping's unit and ignores the record's own, so + * today the row lands as 2.484 m. + */ + walkingDistance: { type: "WALKING_RUNNING_DISTANCE", value: 2484, unit: "m" }, +} as const; + +/** The refusal the extractor raises for an archive with no `export.xml`. */ +export const MISSING_EXPORT_XML_REASON = + "Archive is missing the `apple_health_export/export.xml` member"; + +const EXPORT_XML = ` +]> + + + + + + + + + + + + + + +`; + +interface ZipMember { + name: string; + payload: Buffer; +} + +/** + * Minimal ZIP writer — deflate-compressed members, no Zip64, fixed + * (zeroed) modification stamps so the same members always produce the same + * bytes. Mirrors the hand-built archives in + * `src/lib/import/__tests__/unzip-export-xml.test.ts`; the extractor under + * test reads the central directory, which is what this emits. + */ +function buildZip(members: readonly ZipMember[]): Buffer { + const locals: Buffer[] = []; + const central: Buffer[] = []; + let offset = 0; + + for (const member of members) { + const nameBuf = Buffer.from(member.name, "utf8"); + const compressed = deflateRawSync(member.payload); + const crc = crc32(member.payload); + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); // version needed + localHeader.writeUInt16LE(0, 6); // flags + localHeader.writeUInt16LE(8, 8); // method: deflate + localHeader.writeUInt16LE(0, 10); // mtime + localHeader.writeUInt16LE(0, 12); // mdate + localHeader.writeUInt32LE(crc, 14); + localHeader.writeUInt32LE(compressed.length, 18); + localHeader.writeUInt32LE(member.payload.length, 22); + localHeader.writeUInt16LE(nameBuf.length, 26); + localHeader.writeUInt16LE(0, 28); // extra length + + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(0x02014b50, 0); + centralHeader.writeUInt16LE(20, 4); // version made by + centralHeader.writeUInt16LE(20, 6); // version needed + centralHeader.writeUInt16LE(0, 8); // flags + centralHeader.writeUInt16LE(8, 10); // method: deflate + centralHeader.writeUInt16LE(0, 12); // mtime + centralHeader.writeUInt16LE(0, 14); // mdate + centralHeader.writeUInt32LE(crc, 16); + centralHeader.writeUInt32LE(compressed.length, 20); + centralHeader.writeUInt32LE(member.payload.length, 24); + centralHeader.writeUInt16LE(nameBuf.length, 28); + centralHeader.writeUInt16LE(0, 30); // extra + centralHeader.writeUInt16LE(0, 32); // comment + centralHeader.writeUInt16LE(0, 34); // disk number + centralHeader.writeUInt16LE(0, 36); // internal attrs + centralHeader.writeUInt32LE(0, 38); // external attrs + centralHeader.writeUInt32LE(offset, 42); + + locals.push(localHeader, nameBuf, compressed); + central.push(centralHeader, nameBuf); + offset += localHeader.length + nameBuf.length + compressed.length; + } + + const centralBuf = Buffer.concat(central); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); // disk + eocd.writeUInt16LE(0, 6); // disk with central directory + eocd.writeUInt16LE(members.length, 8); + eocd.writeUInt16LE(members.length, 10); + eocd.writeUInt32LE(centralBuf.length, 12); + eocd.writeUInt32LE(offset, 16); + eocd.writeUInt16LE(0, 20); // comment length + + return Buffer.concat([...locals, centralBuf, eocd]); +} + +/** A well-formed export archive carrying the five fixture records. */ +export function appleHealthExportZip(): Buffer { + return buildZip([ + { + name: "apple_health_export/export.xml", + payload: Buffer.from(EXPORT_XML, "utf8"), + }, + ]); +} + +/** + * An archive that is a valid ZIP and carries no `export.xml` — the shape a + * person produces by zipping the wrong folder. The import must refuse it and + * write nothing. + */ +export function archiveWithoutExportXml(): Buffer { + return buildZip([ + { + name: "apple_health_export/export_cda.xml", + payload: Buffer.from("\n", "utf8"), + }, + ]); +} diff --git a/playwright.config.ts b/playwright.config.ts index 928caf004..e71dcacad 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -154,6 +154,15 @@ export default defineConfig({ "vaccinations.spec.ts", // Runs only in the service-worker project. "v137-record-session-fence-offline.spec.ts", + // The Apple Health import journey uploads an archive into the one + // shared account and its refusal control asserts that the account's + // Apple-Health row count did not move. A second project running the + // same journey in parallel would import beside it and flip that + // verdict — and both would be spending the kick-off route's + // three-uploads-per-minute budget on each other. It proves a flow + // through stable data attributes, not a mobile layout, so it runs in + // one project. + "apple-health-import.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 4ffd2d8be..dbe48c210 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -343,6 +343,29 @@ export function MeasurementList({ }, [unitDisplay, glucoseUnit], ); + /** + * Machine-readable mirror of what the row renders — the same number and + * the same unit, before `Intl` turns them into locale text. + * + * The rendered cell is the only place a reading's value and unit appear + * together, and it appears there as "8.421 steps" or "8,421 steps" + * depending on who is looking. A browser test that has to assert a stored + * unit therefore either pins a locale or matches a loose number, and both + * describe the formatter rather than the reading. These attributes carry + * the reading itself, and the desktop table and the mobile list emit the + * same set, so an assertion holds at either viewport. + */ + const rowIdentityAttrs = useCallback( + (m: Measurement, isGrouped: boolean) => ({ + "data-testid": "measurement-row", + "data-measurement-type": m.type, + "data-measurement-value": String( + isGrouped ? m.value : rowDisplay(m).value, + ), + "data-measurement-unit": isGrouped ? m.unit : rowDisplay(m).unit, + }), + [rowDisplay], + ); // v1.28.42 (H3) — the desktop table and the mobile card list used to BOTH // render (only CSS `display` hid one), so a dense cumulative (up to ~5000 // rows) or sleep page built every row subtree twice — double the DOM, double @@ -1161,6 +1184,7 @@ export function MeasurementList({ {/* Grouped/synthetic rows aren't individually @@ -1358,6 +1382,7 @@ export function MeasurementList({
diff --git a/src/components/settings/import-panel/apple-health-import-card.tsx b/src/components/settings/import-panel/apple-health-import-card.tsx index 4bbf87135..344299c81 100644 --- a/src/components/settings/import-panel/apple-health-import-card.tsx +++ b/src/components/settings/import-panel/apple-health-import-card.tsx @@ -499,6 +499,7 @@ export function AppleHealthImportCard() { )} {(isFailed || uploadError) && (

From 1de3681c81349fb30428e0ee5ca2ecad25a8ec53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:01:11 +0200 Subject: [PATCH 36/50] test(e2e): cover the blood-glucose journey across a unit switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glucose display unit had thirty-odd readers and, until recently, no control to set it. Nothing in the browser suite walks the path a person actually takes: capture a reading in mg/dL, switch the account to mmol/L, capture a second one, and read both back — each converted, none of them reinterpreted — in the list and on the dashboard strip. The journey covers that end to end, plus the boundary: a value past the plausible ceiling is refused by the server, the capture keeps what was typed, and the list is exactly what it was. One reading arrives with no meal-time context, the way a meter export or a device sync delivers one. It reads back correctly in the list; the tile it should paint does not exist yet (#943), so the tile assertion is skipped by issue number rather than softened into something that passes. Three supporting changes: - The list rows carry what they hold — type and resolved display unit on the row, a slot on the value cell — so the journey pins "this reading now reads in mmol/L" structurally instead of matching translated copy that also paints through a different subtree on the card list. The meal-time options carry their enum for the same reason. - The sharing-marker weight rows were seeded by handing a Date to a `timestamp without time zone` column, which stamps them in the host's zone. On a machine east of UTC that put them hours in the future, where every fold whose window ends "now" skips them, and the type stayed uncovered — which keeps the snapshot's thick phase cold and the whole tile strip short. Seed the instant as UTC, the shape the app's own writes use. - The file mutates one shared account and reads global verdicts off it, so it runs in a single project alongside the visits and immunization journeys. --- e2e/glucose-journey.spec.ts | 418 ++++++++++++++++++ e2e/setup/global-setup.ts | 7 +- e2e/setup/glucose-fixture.ts | 67 +++ playwright.config.ts | 8 + .../measurements/measurement-form.tsx | 9 +- .../measurements/measurement-list.tsx | 28 +- 6 files changed, 533 insertions(+), 4 deletions(-) create mode 100644 e2e/glucose-journey.spec.ts create mode 100644 e2e/setup/glucose-fixture.ts diff --git a/e2e/glucose-journey.spec.ts b/e2e/glucose-journey.spec.ts new file mode 100644 index 000000000..b8f545b22 --- /dev/null +++ b/e2e/glucose-journey.spec.ts @@ -0,0 +1,418 @@ +/** + * Blood glucose, from the first reading to the tile — one journey. + * + * The flow this file is checked against, in one script: + * + * 1. a reading arrives with no meal-time context, the way a meter export or + * a device sync delivers one; + * 2. a person captures a reading by hand in mg/dL; + * 3. the list shows both in mg/dL; + * 4. they switch the account's display unit to mmol/L in settings; + * 5. the capture form now ASKS in mmol/L, and a reading typed there is + * stored canonically — the list shows all three in mmol/L, each one + * converted, none of them reinterpreted; + * 6. the dashboard paints the blood-glucose tile in the same unit. + * + * Refusing control: a value outside the accepted mg/dL band is refused by the + * server, the capture stays open with an error, and the list is exactly what + * it was. A journey that only ever writes acceptable numbers proves the happy + * path and nothing about the boundary. + * + * Every assertion addresses a stable attribute — `data-testid`, + * `data-measurement-type`, `data-display-unit`, `data-slot`, a placeholder — + * never rendered copy: the labels are i18n-driven, and the same rows paint + * through a different subtree on the card list. + * + * The flows mutate the one seeded account (its glucose rows AND its display + * unit), so — like `visits.spec.ts` and `vaccinations.spec.ts` — this file + * runs in a single project (see `playwright.config.ts`), runs serial, and + * resets both before each test. + */ +import type { Page } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { resetGlucose } from "./setup/glucose-fixture"; +import { expect, test } from "./setup/test"; + +/** Every blood-glucose row the list is currently painting. */ +const GLUCOSE_ROWS = + '[data-testid="measurement-row"][data-measurement-type="BLOOD_GLUCOSE"]'; + +/** The list has finished its read — rows, or the honest empty state. */ +const LIST_SETTLED = + '[data-slot="measurement-rows"], [data-slot="empty-state"]'; + +/** + * Canonical mg/dL in, display mmol/L out (18.0182 mg/dL per mmol/L, rounded + * to one fraction digit). Written out rather than imported so the spec pins + * the numbers a reader sees instead of re-running the app's own arithmetic + * and agreeing with itself. + */ +const READINGS = { + /** Delivered with no meal-time context. */ + untagged: { mgdl: 112, mmol: "6.2" }, + /** Typed by hand while the account reads mg/dL. */ + typedAsMgdl: { mgdl: 95, mmol: "5.3" }, + /** Typed by hand once the account reads mmol/L; stored as 130 mg/dL. */ + typedAsMmol: { typed: "7.2", mmol: "7.2" }, +} as const; + +/** A locale-tolerant matcher for a decimal the formatter may render "5,3". */ +function decimal(value: string): RegExp { + return new RegExp(value.replace(".", "[.,]")); +} + +async function openList(page: Page): Promise { + await page.goto("/measurements"); + await expect(page.locator(LIST_SETTLED).first()).toBeVisible({ + timeout: 30_000, + }); +} + +/** + * Deliver a reading the way a meter export does: through the batch ingest, + * which is the one write surface that accepts a glucose row with no + * meal-time context. The hand-entry schema requires one — a person at the + * reading knows the answer — so this row cannot be produced by the form, and + * producing it any other way would be testing a fixture rather than a + * contract. + * + * The POST rides the PAGE's own `fetch`, not a shared Playwright request + * context: a pooled context can hand a POST to a keep-alive socket the server + * has already closed, and the retry lands as a second row. + */ +async function deliverUntaggedReading(page: Page, mgdl: number): Promise { + const result = await page.evaluate(async (value: number) => { + const at = new Date().toISOString(); + const res = await fetch("/api/measurements/batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBloodGlucose", + value, + unit: "mg/dL", + startDate: at, + endDate: at, + externalId: `e2e-glucose-untagged-${at}`, + source: "MANUAL", + }, + ], + }), + }); + return { + status: res.status, + body: (await res.json()) as { data?: { inserted?: number } }, + }; + }, mgdl); + + expect(result.status, "the batch ingest accepted the untagged row").toBe(200); + expect(result.body.data?.inserted).toBe(1); +} + +/** + * Fold the account's measurement rollups, synchronously, through the app's own + * operator endpoint. + * + * The dashboard's glucose tiles ride the snapshot's THICK phase, and that + * phase refuses to run until the rollup tier is warm for the types the account + * holds — a cold tier would drop the whole strip into a live-SQL fallback and + * make every tile wait on the slowest read. Rows seeded straight into Postgres + * by `global-setup` are never folded (nothing hooks a raw INSERT), so on a + * freshly created database the phase is cold, `extras` comes back null, and + * the tile a person would see on their own instance is simply absent here. + * + * That is a property of the harness, not of the feature, so the journey warms + * the tier rather than asserting around it: one call, awaited, before the + * dashboard read. + */ +async function warmRollupTier(page: Page): Promise { + const result = await page.evaluate(async () => { + const me = (await (await fetch("/api/auth/me")).json()) as { + data: { id: string }; + }; + const res = await fetch("/api/admin/rollups/recompute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: me.data.id }), + }); + return { + status: res.status, + body: (await res.json()) as { data?: { rowsUpserted?: number } }, + }; + }); + + expect(result.status, "the rollup fold ran").toBe(200); + expect(result.body.data?.rowsUpserted ?? 0).toBeGreaterThan(0); +} + +/** The ISO day before the one the capture form seeded itself with. */ +function previousDay(isoDay: string): string { + const day = new Date(`${isoDay}T00:00:00.000Z`); + day.setUTCDate(day.getUTCDate() - 1); + return day.toISOString().slice(0, 10); +} + +/** + * Drive the capture sheet through the `?add=` deep link the Insights actions + * already use, fill the single value field, and save. Returns the status the + * server answered the write with, so a caller can assert the refusal without + * reading it back off the copy in the banner. + */ +async function captureReading( + page: Page, + opts: { + typed: string; + context?: string; + expectPlaceholder?: string; + /** Backdate to `HH:mm` on the day before the form's own default. */ + yesterdayAt?: string; + }, +): Promise { + await page.goto("/measurements?add=BLOOD_GLUCOSE"); + + const sheet = page.locator('[data-slot="responsive-sheet-content"]'); + await expect(sheet).toBeVisible({ timeout: 30_000 }); + + const valueField = sheet.locator("#value"); + await expect(valueField).toBeVisible(); + if (opts.expectPlaceholder) { + // The placeholder is the form saying which unit it is asking in, in an + // attribute rather than in copy. + await expect(valueField).toHaveAttribute( + "placeholder", + opts.expectPlaceholder, + ); + } + await valueField.fill(opts.typed); + + if (opts.yesterdayAt) { + // Two readings that share a minute share a row identity — `(user, type, + // measuredAt)` is unique and the second one is refused — so a journey + // that captures twice has to place them, exactly as a person would. + // Yesterday rather than an earlier hour today: any hour is in the past on + // a past day, so the picker's no-future clamp cannot pull the instant + // back onto the same minute as the other reading whatever the hour the + // suite happens to run at. + const dateField = page.locator( + '[data-slot="date-time-field"] [data-slot="date-field"]', + ); + const seededDay = await dateField + .locator('input[type="hidden"]') + .inputValue(); + const day = previousDay(seededDay); + // The overlay parses a clean ISO string ahead of the account's own + // date-format preference, so the spec does not have to know it. + await dateField.locator('input[type="text"]').fill(day); + await dateField.locator('input[type="text"]').blur(); + await expect(dateField.locator('input[type="hidden"]')).toHaveValue(day); + + const timeField = page.locator( + '[data-slot="date-time-field"] [data-slot="time-field"]', + ); + await timeField.locator('input[type="text"]').fill(opts.yesterdayAt); + await timeField.locator('input[type="text"]').blur(); + await expect(timeField.locator('input[type="hidden"]')).toHaveValue( + opts.yesterdayAt, + ); + } + + if (opts.context) { + // The meal-time context defaults to fasting; anything else is a choice + // the person makes, addressed by the enum the option carries. + await sheet.locator("#glucose-context").click(); + const option = page.locator( + `[data-slot="select-item"][data-glucose-context="${opts.context}"]`, + ); + await expect(option).toBeVisible(); + await option.click(); + await expect(option).toHaveCount(0); + } + + const write = page.waitForResponse( + (res) => + new URL(res.url()).pathname === "/api/measurements" && + res.request().method() === "POST", + ); + await sheet + .locator('[data-slot="responsive-sheet-footer"] button[type="submit"]') + .click(); + return (await write).status(); +} + +/** Switch the account's blood-glucose display unit through settings. */ +async function chooseGlucoseUnit(page: Page, unit: string): Promise { + await page.goto("/settings/account"); + + const select = page.getByTestId("settings-glucose-unit-select"); + await expect(select).toBeVisible({ timeout: 30_000 }); + // The control disables itself until the account payload has landed; a + // change dispatched before that is dropped by its own guard. + await expect(select).toBeEnabled(); + + const saved = page.waitForResponse( + (res) => + new URL(res.url()).pathname === "/api/auth/me/glucose-unit" && + res.request().method() === "PATCH", + ); + await select.selectOption(unit); + expect((await saved).status()).toBe(200); + await expect(select).toHaveValue(unit); +} + +test.beforeEach(async () => { + // Own every row the counts below read, and undo the switch the journey + // itself performs — the unit is a column on the shared account, so a run + // that left it on mmol/L would start the next one past the half it means + // to prove. + await resetGlucose(); +}); + +// Serial and single-account: the reset above would race a sibling worker, and +// two tests clearing the same rows would each undo the other's setup. +test.describe.configure({ mode: "serial" }); + +test.describe("blood glucose", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + // The dashboard is the most expensive page in the product to render cold, + // and this journey ends there after four navigations. The budget is the + // page's; the assertions stay exactly as tight as they were. + test.setTimeout(120_000); + + test("readings captured either side of a unit switch all read in the chosen unit", async ({ + page, + }) => { + await openList(page); + await deliverUntaggedReading(page, READINGS.untagged.mgdl); + await warmRollupTier(page); + + // Hand entry, while the account still reads mg/dL. + expect( + await captureReading(page, { + typed: String(READINGS.typedAsMgdl.mgdl), + yesterdayAt: "07:30", + expectPlaceholder: "95", + }), + ).toBe(201); + + await openList(page); + const rows = page.locator(GLUCOSE_ROWS); + await expect(rows).toHaveCount(2); + await expect(rows.first()).toHaveAttribute("data-display-unit", "mg/dL"); + await expect( + page.locator(`${GLUCOSE_ROWS}[data-display-unit="mg/dL"]`), + ).toHaveCount(2); + + // The switch. + await chooseGlucoseUnit(page, "mmol/L"); + + // The form now ASKS in mmol/L — its placeholder says so — and what it + // stores is still canonical mg/dL, which is what the read-back proves. + // Bedtime rather than fasting, so each hand-entered reading owns a tile + // and each tile's "latest" names exactly one of them — a strip where two + // readings share a bucket would prove only that one of them won. + expect( + await captureReading(page, { + typed: READINGS.typedAsMmol.typed, + context: "BEDTIME", + expectPlaceholder: "5.3", + }), + ).toBe(201); + + await openList(page); + await expect(rows).toHaveCount(3); + await expect( + page.locator(`${GLUCOSE_ROWS}[data-display-unit="mmol/L"]`), + ).toHaveCount(3); + + // Each reading converted, none reinterpreted: 112 → 6.2, 95 → 5.3, and + // the 7.2 typed in mmol/L comes back as the 7.2 it was. + for (const expected of [ + READINGS.untagged.mmol, + READINGS.typedAsMgdl.mmol, + READINGS.typedAsMmol.mmol, + ]) { + await expect( + rows + .locator('[data-slot="measurement-row-value"]') + .filter({ hasText: decimal(expected) }), + ).toHaveCount(1); + } + + // The dashboard reads the same account preference: one tile per meal-time + // context, each carrying its own reading in mmol/L. + await page.goto("/"); + for (const [tileId, value] of [ + ["glucose-FASTING", READINGS.typedAsMgdl.mmol], + ["glucose-BEDTIME", READINGS.typedAsMmol.mmol], + ] as const) { + const tile = page.locator( + `[data-slot="dashboard-tile-link"][data-tile-id="${tileId}"]`, + ); + await expect(tile).toBeVisible({ timeout: 60_000 }); + await expect(tile.locator('[data-slot="trend-card-value"]')).toHaveText( + decimal(value), + ); + } + }); + + test("a reading with no meal-time context gets a tile of its own", async ({ + page, + }) => { + test.skip( + true, + "#943 — tile eligibility filters the four named meal-time contexts " + + "against the per-context summaries, so an account whose source records " + + "no meal time matches none of them and the strip is silently short one " + + "card. The fix names the untagged bucket and walks it with the rest; it " + + "is not on main yet. Unskip with it, do not weaken the assertion — a " + + "check that cannot fail is worse than none.", + ); + + await openList(page); + await deliverUntaggedReading(page, READINGS.untagged.mgdl); + await warmRollupTier(page); + + await page.goto("/"); + const tile = page.locator( + '[data-slot="dashboard-tile-link"][data-tile-id="glucose-UNSPECIFIED"]', + ); + await expect(tile).toBeVisible({ timeout: 60_000 }); + await expect(tile.locator('[data-slot="trend-card-value"]')).toHaveText( + decimal(String(READINGS.untagged.mgdl)), + ); + }); + + test("a value outside the accepted band is refused and the list is unchanged", async ({ + page, + }) => { + await openList(page); + await deliverUntaggedReading(page, READINGS.untagged.mgdl); + + await openList(page); + await expect(page.locator(GLUCOSE_ROWS)).toHaveCount(1); + + // 900 mg/dL is past the plausible ceiling (20–800). The field carries no + // min/max of its own, so the refusal is the server's and this is a real + // round trip, not a browser-side block. + expect(await captureReading(page, { typed: "900" })).toBe(422); + + // The capture stays open, carrying the error and the entered value: a + // refusal must not cost the person what they typed. + const sheet = page.locator('[data-slot="responsive-sheet-content"]'); + await expect(sheet.getByRole("alert")).toBeVisible(); + await expect(sheet).toBeVisible(); + await expect(sheet.locator("#value")).toHaveValue("900"); + + // And the list behind it is exactly what it was. + await openList(page); + const rows = page.locator(GLUCOSE_ROWS); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toHaveAttribute("data-display-unit", "mg/dL"); + await expect( + rows.locator('[data-slot="measurement-row-value"]'), + ).toHaveText(decimal(String(READINGS.untagged.mgdl))); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 14f0343ad..9ee0f42ec 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -675,7 +675,12 @@ export default async function globalSetup(config: FullConfig): Promise { SELECT $1, u.id, 'WEIGHT', $2, 'kg', 'MANUAL', $3, 'e2e-sharing-marker', $3, $3 FROM users u WHERE u.username = $4`, - [cuid(), kg, now, username], + // `measured_at` is `timestamp without time zone` and the driver + // serialises a `Date` in the HOST's zone, so a developer machine east + // of UTC seeded this row hours into the FUTURE — where every fold + // whose window ends "now" skips it, and the type stays uncovered. + // Send the instant as UTC, the same shape the app's own writes take. + [cuid(), kg, now.toISOString(), username], ); } diff --git a/e2e/setup/glucose-fixture.ts b/e2e/setup/glucose-fixture.ts new file mode 100644 index 000000000..6c1748995 --- /dev/null +++ b/e2e/setup/glucose-fixture.ts @@ -0,0 +1,67 @@ +/** + * Account hygiene for the blood-glucose journey. + * + * `glucose-unit-journey.spec.ts` counts rows and reads a display unit off + * them, and both verdicts are global: "the list holds three glucose readings, + * all of them in mmol/L" is only true of an account whose glucose history the + * test itself wrote. A reading left by an earlier run, by a Playwright retry, + * or by a sibling worker would add a fourth row and a stale unit, and the + * assertions would read somebody else's data. + * + * The display unit needs the same treatment, and for a sharper reason: the + * journey's whole subject is switching it, so the spec ENDS with the shared + * account on mmol/L. Left there, the next run's mg/dL half would start from + * the answer it is trying to reach — a test that can only pass once is worse + * than no test. `resetGlucose()` puts the column back to NULL (the default + * mg/dL resolution) before every test. + * + * Deletes rather than soft-deletes: the list route filters on `deleted_at IS + * NULL`, but the dashboard tile and the analytics summaries are the other half + * of this journey and a soft-deleted row would still have to be reasoned + * about. These are fixture rows, so they go. + */ +import pg from "pg"; + +import { E2E_USER } from "./global-setup"; + +async function withPool(fn: (pool: pg.Pool) => Promise): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[glucose-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + return await fn(pool); + } finally { + await pool.end(); + } +} + +async function getUserId(pool: pg.Pool): Promise { + const res = await pool.query<{ id: string }>( + "SELECT id FROM users WHERE username = $1", + [E2E_USER.username], + ); + const id = res.rows[0]?.id; + if (!id) { + throw new Error( + "[glucose-fixture] e2e user not seeded — global-setup must run first", + ); + } + return id; +} + +/** + * Clear the seeded account's blood-glucose history and put its display unit + * back to the default. Safe to call from every test's `beforeEach`. + */ +export async function resetGlucose(): Promise { + await withPool(async (pool) => { + const userId = await getUserId(pool); + await pool.query( + "DELETE FROM measurements WHERE user_id = $1 AND type = 'BLOOD_GLUCOSE'", + [userId], + ); + await pool.query("UPDATE users SET glucose_unit = NULL WHERE id = $1", [ + userId, + ]); + }); +} diff --git a/playwright.config.ts b/playwright.config.ts index 928caf004..fb750fd73 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -152,6 +152,14 @@ export default defineConfig({ // through stable data-slots, not a mobile layout, so it runs in one // project. "vaccinations.spec.ts", + // The blood-glucose journey writes readings to the one shared account + // and then reads GLOBAL verdicts off it — three rows in the list, one + // tile per meal-time context — while also moving that account's + // display-unit column. Two projects mutating it in parallel would + // leave a fourth row in the count and could flip the unit under the + // other one mid-navigation. It proves a flow through stable + // data-slots, not a mobile layout, so it runs in one project. + "glucose-journey.spec.ts", // Runs only in the service-worker project. "v137-record-session-fence-offline.spec.ts", ], diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 40ea77036..5cc7e23e0 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -605,7 +605,14 @@ export function MeasurementForm({ {GLUCOSE_CONTEXTS.map((ctx) => ( - + {t(ctx.labelKey)} ))} diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 4ffd2d8be..210aad325 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -1161,6 +1161,18 @@ export function MeasurementList({ {/* Grouped/synthetic rows aren't individually @@ -1197,7 +1209,10 @@ export function MeasurementList({ - + {/* v1.4.39.3 — non-grouped rows render the stored value with its native precision (`fmt.number` honours up to 3 fraction @@ -1358,6 +1373,12 @@ export function MeasurementList({

@@ -1412,7 +1433,10 @@ export function MeasurementList({ {t(TYPE_LABEL_KEYS[m.type])} )} - + {/* v1.4.39.3 — mirror the desktop table: grouped daily aggregates stay integer, non-grouped single readings render with From 8995bef61c89310d0d8bcad41b2092d757794696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:04:30 +0200 Subject: [PATCH 37/50] feat(ops): synthetic post-deploy journey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-publish verify proves the published image boots. Nothing proved that on a running instance a person can sign in, write a reading and read it back — which is exactly the shape of the defects this line keeps rediscovering: a value written and never read. `scripts/synthetic-journey.mjs` walks that journey in plain Node 22 with no dependencies, so it runs from a workstation, a runner or the deploy host. It warms `/`, `/api/health`, `/api/version` and the measurements list first and discards those timings (the first hit on each route after a deploy is a cold start), then asserts five legs in order: the served version equals the expected one, the password flow signs in through a cookie jar, a weight reading is written with an Idempotency-Key, the same row reads back with its value, unit and source, and the reversible delete leaves the account clean. One line per leg with the elapsed time; the first failure ends the run non-zero with the status and a body excerpt that has every token shape redacted out of it. The workflow is `workflow_dispatch` only, with `base_url` and `expected_version` as inputs and the credentials read from repository secrets. It is deliberately not wired into the deploy path: the target instance and the secrets are the maintainer's call. Mutation proof, against a production build served from `.next/standalone/server.js` on a throwaway Postgres. Green: warm 35 ms (discarded) ok version 2 ms serves 1.38.13 ok sign in 78 ms session cookie held ok write 64 ms wrote cmtup9lwu0007zhovn0mxajdz ok read back 8 ms 77.7 kg (MANUAL) ok delete 27 ms account left clean synthetic journey green Read leg broken deliberately — the same server behind a throwaway forwarder that empties the `GET /api/measurements` list and changes nothing else: ok version 4 ms serves 1.38.13 ok sign in 38 ms session cookie held ok write 40 ms wrote cmtupa4ck000gzhovwnij64fz FAILED read back 8 ms read back: the list came back without the reading status 200 body {"data":{"measurements":[],"meta":{"total":0}},"error":null} synthetic journey RED on "read back" Version leg broken the same way, by asking the instance for the previous tag: FAILED version 3 ms version: instance serves "1.38.13", expected "v1.38.12" synthetic journey RED on "version" `--self-test` carries that proof in the repository: it runs the journey against an in-process instance nine times, once with nothing broken and once per broken leg, and fails if a broken leg still comes back green. The workflow runs it before it touches the real instance. Gates: typecheck, `node --check`, lint (the three known warnings, no errors), prettier, the workflow and CI-determinism guards. --- .github/workflows/synthetic-journey.yml | 63 +++ docs/ops/deploy.md | 49 ++ scripts/synthetic-journey.mjs | 583 ++++++++++++++++++++++++ 3 files changed, 695 insertions(+) create mode 100644 .github/workflows/synthetic-journey.yml create mode 100644 scripts/synthetic-journey.mjs diff --git a/.github/workflows/synthetic-journey.yml b/.github/workflows/synthetic-journey.yml new file mode 100644 index 000000000..6354bdcc4 --- /dev/null +++ b/.github/workflows/synthetic-journey.yml @@ -0,0 +1,63 @@ +# Synthetic post-deploy journey. +# +# `post-publish-verify.yml` proves the published image boots. This one +# proves that on a running instance a person can sign in, write a reading +# and read it back, and that the instance serves the version that was just +# published. +# +# Deliberately manual for now: the maintainer picks the target instance and +# creates the two repository secrets. Nothing calls this from the deploy +# path yet. +# +# Secrets, both required: +# SYNTHETIC_USERNAME — the probe account's username or email +# SYNTHETIC_PASSWORD — its password +# +# The probe account must be password-only (the password flow stops at the +# MFA ticket, which the script reports as a configuration failure), and the +# target must not be the demo instance, where every mutation is blocked. +name: Synthetic journey + +on: + workflow_dispatch: + inputs: + base_url: + description: Instance to walk, e.g. https://review.healthlog.dev + required: true + type: string + expected_version: + description: Version the instance must serve, e.g. v1.38.13 + required: true + type: string + +permissions: + contents: read + +jobs: + journey: + name: Sign in, write a reading, read it back + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + + # A check that cannot fail is worse than none. The self-test walks the + # journey against an in-process instance once per leg with that leg + # broken, and fails if any of them still comes back green. + - name: Prove the check can go red + run: node scripts/synthetic-journey.mjs --self-test + + # Every input and secret rides an env var — never interpolated into + # the shell command. + - name: Walk the journey + env: + BASE_URL: ${{ inputs.base_url }} + EXPECTED_VERSION: ${{ inputs.expected_version }} + SYNTHETIC_USERNAME: ${{ secrets.SYNTHETIC_USERNAME }} + SYNTHETIC_PASSWORD: ${{ secrets.SYNTHETIC_PASSWORD }} + run: node scripts/synthetic-journey.mjs diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 289efa4dc..6700f779e 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -94,6 +94,55 @@ Pass `--only=prod` / `--only=demo` to check a single target, or `--attempts=N --interval=ms` to widen the retry window while a slow pull settles. +## Walk the synthetic journey + +The version check proves the instance serves the right build. It does not +prove a person can use it. `scripts/synthetic-journey.mjs` walks the +shortest journey that touches the whole stack — sign in, write a reading, +read it back, delete it again — against a running instance: + +```sh +# Plain Node 22, no dependencies: runs from a workstation, a runner, or +# the deploy host. Warms the instance first, then asserts each leg. +BASE_URL=https://review.healthlog.dev \ +SYNTHETIC_USERNAME= \ +SYNTHETIC_PASSWORD= \ +EXPECTED_VERSION=v1.38.13 \ + node scripts/synthetic-journey.mjs +``` + +It prints one line per leg with the elapsed time and stops at the first +failure with the status and a redacted body excerpt. The warm-up hit on +each route is timed but discarded — the first request after a deploy pays +the cold-start cost and says nothing about the instance's steady state. + +The same journey is available as the **Synthetic journey** workflow +(`workflow_dispatch`, inputs `base_url` and `expected_version`), which +reads the credentials from the `SYNTHETIC_USERNAME` / `SYNTHETIC_PASSWORD` +repository secrets. Nothing calls it from the deploy path; run it by hand +after a deploy until a target instance is settled. + +Two preconditions on the probe account: it must be password-only, because +the password flow stops at the MFA ticket for an account with a second +factor, and the target must not be the demo instance, where every mutation +is blocked and the write leg would be red for a reason unrelated to the +deploy. + +**A red result.** The leg that failed names the layer: + +| Red leg | What it means | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `version` | The instance serves a different build than the tag you deployed. Check the image digest and the `pull_policy` before looking anywhere else. | +| `sign in` | Sessions are broken, or the probe account gained a second factor. A 429 here is the login rate limit, not the deploy. | +| `write` | The write path is down: database, encryption keys, or a migration that did not run. | +| `read back` | A value was written and cannot be read back — the shape this project keeps rediscovering. Roll back. | +| `delete` | The write path works and the reversible delete does not; the probe reading is still on the account and needs removing by hand. | + +Run `node scripts/synthetic-journey.mjs --self-test` to confirm the check +itself still works: it walks the journey against an in-process instance +once per leg with that leg broken, and fails if any of them comes back +green. + ## Verify the image signature Every release image (the multi-arch manifest **index**, covering both diff --git a/scripts/synthetic-journey.mjs b/scripts/synthetic-journey.mjs new file mode 100644 index 000000000..e59d7fa21 --- /dev/null +++ b/scripts/synthetic-journey.mjs @@ -0,0 +1,583 @@ +#!/usr/bin/env node +/** + * Synthetic post-deploy journey. + * + * `post-publish-verify.yml` proves the published image boots and answers + * `/api/version`. It does not prove that a person can sign in, write a + * reading and read it back — and every defect the v1.38 line kept + * rediscovering sat in exactly that gap: a value written and never read. + * This script walks the journey against a running instance. + * + * Plain Node, no TypeScript and no dependencies, so it runs from a + * workstation, from a runner, or from the deploy host with nothing + * installed but Node 22. + * + * Legs, in order: + * 0. warm — one GET on `/`, `/api/health`, `/api/version` and the + * measurements list. Timings are discarded: the first hit + * on each route after a deploy is a cold start and says + * nothing about the instance's steady state. + * 1. version — `/api/version` reports EXPECTED_VERSION. + * 2. sign in — the password flow the web client uses (`POST + * /api/auth/login`, session cookie, no CSRF token), through + * a cookie jar. + * 3. write — `POST /api/measurements` with an `Idempotency-Key`. + * 4. read — `GET /api/measurements?type=WEIGHT&limit=1` and assert + * the row's id, value, unit and source. + * 5. delete — `DELETE /api/measurements/{id}` so the account stays clean. + * + * The first failing leg ends the run with a non-zero exit, the response + * status and a short body excerpt. Anything token-shaped in that excerpt + * is redacted before it reaches the log. + * + * Usage: + * BASE_URL=https://review.example.test \ + * SYNTHETIC_USERNAME=… SYNTHETIC_PASSWORD=… EXPECTED_VERSION=v1.38.13 \ + * node scripts/synthetic-journey.mjs + * + * node scripts/synthetic-journey.mjs --self-test + * + * `--self-test` runs the whole journey against an in-process mock: once + * against a healthy mock, which must pass, and once per broken leg, each + * of which must fail. A check that cannot fail is worse than no check, so + * the script carries the proof that it can. + * + * The target must be a real instance, not the demo: demo mode blocks every + * mutation, so the write leg would be red for a reason that has nothing to + * do with the deploy. The account must not carry a second factor — the + * password flow stops at the MFA ticket, which the script reports as a + * configuration failure rather than a deploy failure. + */ + +import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; + +const REQUEST_TIMEOUT_MS = 20_000; +/** The cold start after a deploy can take a while; the warm-up waits it out. */ +const WARM_TIMEOUT_MS = 90_000; +const BODY_EXCERPT_LIMIT = 400; + +/** The reading the journey writes. Distinctive, plausible, and short-lived. */ +const PROBE = { type: "WEIGHT", value: 77.7, unit: "kg", source: "MANUAL" }; + +/** + * Patterns that must never reach a log line. The first three are this + * project's own token prefixes plus the OpenAI-style key shape the + * idempotency cache also refuses to persist; the last two catch a raw + * cookie value and any long opaque blob (a session id, a bearer token, an + * MFA ticket) that a future response shape might carry. + */ +const REDACTIONS = [ + /hlk_[A-Za-z0-9_-]+/g, + /hlr_[A-Za-z0-9_-]+/g, + /hlh_[A-Za-z0-9_-]+/g, + /sk-[A-Za-z0-9_-]+/g, + /(set-cookie|cookie|authorization)\s*[:=]\s*[^\s;,"]+/gi, + /\b[A-Za-z0-9_-]{32,}\b/g, +]; + +function redact(text) { + let out = String(text ?? ""); + for (const pattern of REDACTIONS) out = out.replace(pattern, "[redacted]"); + return out; +} + +function excerpt(text) { + const flat = redact(text).replace(/\s+/g, " ").trim(); + return flat.length > BODY_EXCERPT_LIMIT + ? `${flat.slice(0, BODY_EXCERPT_LIMIT)}…` + : flat; +} + +/** Raised by a leg; carries the status and body the reporter prints. */ +class LegFailure extends Error { + constructor(message, { status, body } = {}) { + super(message); + this.name = "LegFailure"; + this.status = status ?? null; + this.body = body ?? null; + } +} + +/** + * A cookie jar with exactly the behaviour the journey needs: remember what + * the instance set, send it back. No domain or path matching — every + * request goes to the one origin under test. + */ +function createCookieJar() { + const jar = new Map(); + return { + store(response) { + for (const line of response.headers.getSetCookie()) { + const [pair] = line.split(";"); + const index = pair.indexOf("="); + if (index <= 0) continue; + const name = pair.slice(0, index).trim(); + const value = pair.slice(index + 1).trim(); + if (value === "" || value === '""') jar.delete(name); + else jar.set(name, value); + } + }, + header() { + if (jar.size === 0) return undefined; + return [...jar].map(([name, value]) => `${name}=${value}`).join("; "); + }, + }; +} + +async function call(baseUrl, path, options = {}) { + const { method = "GET", body, headers = {}, jar, timeoutMs } = options; + const requestHeaders = { accept: "application/json", ...headers }; + const cookie = jar?.header(); + if (cookie) requestHeaders.cookie = cookie; + if (body !== undefined) requestHeaders["content-type"] = "application/json"; + + const started = performance.now(); + let response; + try { + response = await fetch(new URL(path, baseUrl), { + method, + headers: requestHeaders, + body: body === undefined ? undefined : JSON.stringify(body), + redirect: "manual", + signal: AbortSignal.timeout(timeoutMs ?? REQUEST_TIMEOUT_MS), + }); + } catch (error) { + throw new LegFailure(`${method} ${path} did not answer: ${error.message}`); + } + const text = await response.text(); + jar?.store(response); + + let json = null; + try { + json = JSON.parse(text); + } catch { + /* a non-JSON body is a failure the caller reports with the excerpt */ + } + return { + status: response.status, + text, + json, + elapsedMs: performance.now() - started, + }; +} + +function expectStatus(result, expected, what) { + if (result.status !== expected) { + throw new LegFailure(`${what}: expected HTTP ${expected}`, { + status: result.status, + body: result.text, + }); + } +} + +function expectValue(actual, expected, what, result) { + if (actual !== expected) { + throw new LegFailure( + `${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + { status: result?.status, body: result?.text }, + ); + } +} + +/** `v1.38.13` and `1.38.13` are the same build; the tag carries the `v`. */ +function normaliseVersion(value) { + return String(value ?? "") + .trim() + .replace(/^v/i, ""); +} + +async function warm(baseUrl, jar) { + for (const path of [ + "/", + "/api/health", + "/api/version", + "/api/measurements?type=WEIGHT&limit=1", + ]) { + // Status and timing are both deliberately ignored. `/` redirects to the + // login page for an anonymous caller, `/api/health` answers 503 while a + // worker is still coming up, and the measurements list is 401 before the + // sign-in leg. Warming is about paying the compile cost, not asserting. + await call(baseUrl, path, { jar, timeoutMs: WARM_TIMEOUT_MS }).catch( + () => null, + ); + } +} + +async function assertVersion(baseUrl, jar, expectedVersion) { + const result = await call(baseUrl, "/api/version", { jar }); + expectStatus(result, 200, "version"); + const served = result.json?.data?.version; + if (normaliseVersion(served) !== normaliseVersion(expectedVersion)) { + throw new LegFailure( + `version: instance serves ${JSON.stringify(served)}, expected ${JSON.stringify(expectedVersion)}`, + { status: result.status, body: result.text }, + ); + } + return { detail: `serves ${served}` }; +} + +async function signIn(baseUrl, jar, username, password) { + const result = await call(baseUrl, "/api/auth/login", { + method: "POST", + jar, + body: { email: username, password }, + }); + expectStatus(result, 200, "sign in"); + if (result.json?.meta?.mfaRequired) { + throw new LegFailure( + "sign in: the account asks for a second factor — the synthetic account must be password-only", + { status: result.status }, + ); + } + const userId = result.json?.data?.user?.id; + if (typeof userId !== "string" || userId === "") { + throw new LegFailure("sign in: no user in the response envelope", { + status: result.status, + body: result.text, + }); + } + if (!jar.header()) { + throw new LegFailure("sign in: the instance set no session cookie", { + status: result.status, + }); + } + return { detail: "session cookie held" }; +} + +async function writeReading(baseUrl, jar) { + const measuredAt = new Date().toISOString(); + const result = await call(baseUrl, "/api/measurements", { + method: "POST", + jar, + headers: { "idempotency-key": randomUUID() }, + body: { + type: PROBE.type, + value: PROBE.value, + measuredAt, + source: PROBE.source, + }, + }); + expectStatus(result, 201, "write"); + const created = result.json?.data; + if (typeof created?.id !== "string" || created.id === "") { + throw new LegFailure("write: no measurement id in the response envelope", { + status: result.status, + body: result.text, + }); + } + return { id: created.id, measuredAt, detail: `wrote ${created.id}` }; +} + +async function readBack(baseUrl, jar, written) { + const result = await call( + baseUrl, + "/api/measurements?type=WEIGHT&limit=1&sortBy=measuredAt&sortDir=desc", + { jar }, + ); + expectStatus(result, 200, "read back"); + const rows = result.json?.data?.measurements; + if (!Array.isArray(rows) || rows.length === 0) { + throw new LegFailure("read back: the list came back without the reading", { + status: result.status, + body: result.text, + }); + } + const row = rows[0]; + expectValue(row.id, written.id, "read back: id", result); + expectValue(row.value, PROBE.value, "read back: value", result); + expectValue(row.unit, PROBE.unit, "read back: unit", result); + expectValue(row.source, PROBE.source, "read back: source", result); + return { detail: `${row.value} ${row.unit} (${row.source})` }; +} + +async function removeReading(baseUrl, jar, written) { + const result = await call(baseUrl, `/api/measurements/${written.id}`, { + method: "DELETE", + jar, + }); + expectStatus(result, 200, "delete"); + return { detail: "account left clean" }; +} + +/** + * Run the journey. Returns `{ ok: true }` or `{ ok: false, leg, error }` — + * the caller decides what an exit code is, which is what lets the self-test + * assert a red run without ending the process. + */ +async function runJourney(config, log = console.log) { + const { baseUrl, username, password, expectedVersion } = config; + const jar = createCookieJar(); + + const warmStarted = performance.now(); + await warm(baseUrl, jar); + log( + `warm ${Math.round(performance.now() - warmStarted)} ms (discarded)`, + ); + + let written = null; + const legs = [ + { + name: "version", + run: () => assertVersion(baseUrl, jar, expectedVersion), + }, + { name: "sign in", run: () => signIn(baseUrl, jar, username, password) }, + { + name: "write", + run: async () => (written = await writeReading(baseUrl, jar)), + }, + { name: "read back", run: () => readBack(baseUrl, jar, written) }, + { name: "delete", run: () => removeReading(baseUrl, jar, written) }, + ]; + + for (const leg of legs) { + const started = performance.now(); + try { + const outcome = await leg.run(); + const elapsed = Math.round(performance.now() - started); + log( + `ok ${leg.name.padEnd(10)} ${String(elapsed).padStart(6)} ms ${outcome?.detail ?? ""}`.trimEnd(), + ); + } catch (error) { + const elapsed = Math.round(performance.now() - started); + log( + `FAILED ${leg.name.padEnd(10)} ${String(elapsed).padStart(6)} ms ${redact(error.message)}`, + ); + if (error instanceof LegFailure) { + if (error.status !== null) log(` status ${error.status}`); + if (error.body) log(` body ${excerpt(error.body)}`); + } else { + log(` ${redact(error.stack ?? "")}`); + } + return { ok: false, leg: leg.name, error }; + } + } + return { ok: true }; +} + +/* ------------------------------------------------------------------ * + * Self-test: the journey against an in-process instance. + * ------------------------------------------------------------------ */ + +const SELF_TEST_VERSION = "v9.99.0"; +const SELF_TEST_CREDENTIALS = { username: "journey", password: "journey-pass" }; + +/** + * A mock that answers the five legs. `breaks` names the leg to sabotage, + * which is how the self-test proves each assertion can go red. + */ +function startMockInstance(breaks) { + const rows = new Map(); + const server = createServer((request, response) => { + const url = new URL(request.url, "http://mock.invalid"); + const send = (status, payload, headers = {}) => { + response.writeHead(status, { + "content-type": "application/json", + ...headers, + }); + response.end(JSON.stringify(payload)); + }; + const authenticated = (request.headers.cookie ?? "").includes( + "hl_session=", + ); + + if (url.pathname === "/api/version") { + const version = breaks === "version" ? "v9.98.0" : SELF_TEST_VERSION; + return send(200, { data: { version, buildSha: null }, error: null }); + } + if (url.pathname === "/api/health") + return send(200, { data: {}, error: null }); + if (url.pathname === "/") return send(200, { data: null, error: null }); + + if (url.pathname === "/api/auth/login") { + let body = ""; + request.on("data", (chunk) => (body += chunk)); + request.on("end", () => { + const parsed = JSON.parse(body || "{}"); + if ( + parsed.email !== SELF_TEST_CREDENTIALS.username || + parsed.password !== SELF_TEST_CREDENTIALS.password + ) { + return send(401, { data: null, error: "Invalid credentials" }); + } + if (breaks === "sign in") { + return send(200, { + data: null, + error: null, + meta: { mfaRequired: true }, + }); + } + send( + 200, + { + data: { user: { id: "user-1", username: parsed.email } }, + error: null, + }, + { "set-cookie": "hl_session=mock-session; Path=/; HttpOnly" }, + ); + }); + return; + } + + if (!authenticated) return send(401, { data: null, error: "Unauthorized" }); + + if (url.pathname === "/api/measurements" && request.method === "POST") { + if (breaks === "write") { + return send(500, { data: null, error: "write failed" }); + } + let body = ""; + request.on("data", (chunk) => (body += chunk)); + request.on("end", () => { + const parsed = JSON.parse(body || "{}"); + const row = { + id: `m-${rows.size + 1}`, + type: parsed.type, + value: parsed.value, + unit: PROBE.unit, + source: parsed.source, + measuredAt: parsed.measuredAt, + }; + rows.set(row.id, row); + send(201, { data: row, error: null }); + }); + return; + } + + if (url.pathname === "/api/measurements" && request.method === "GET") { + let listed = [...rows.values()]; + // The mutation the plan's gate names: the read leg is broken and the + // check has to go red. + if (breaks === "read back: missing") listed = []; + if (breaks === "read back: value") { + listed = listed.map((row) => ({ ...row, value: row.value + 1 })); + } + if (breaks === "read back: unit") { + listed = listed.map((row) => ({ ...row, unit: "lb" })); + } + if (breaks === "read back: source") { + listed = listed.map((row) => ({ ...row, source: "IMPORT" })); + } + return send(200, { + data: { measurements: listed, meta: { total: listed.length } }, + error: null, + }); + } + + if ( + url.pathname.startsWith("/api/measurements/") && + request.method === "DELETE" + ) { + if (breaks === "delete") { + return send(404, { data: null, error: "Measurement not found" }); + } + rows.delete(url.pathname.split("/").pop()); + return send(200, { data: { success: true }, error: null }); + } + + send(404, { data: null, error: "Not found" }); + }); + + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ + baseUrl: `http://127.0.0.1:${port}`, + close: () => new Promise((done) => server.close(done)), + }); + }); + }); +} + +async function selfTest() { + const cases = [ + { breaks: null, expect: "pass" }, + { breaks: "version", expect: "fail", leg: "version" }, + { breaks: "sign in", expect: "fail", leg: "sign in" }, + { breaks: "write", expect: "fail", leg: "write" }, + { breaks: "read back: missing", expect: "fail", leg: "read back" }, + { breaks: "read back: value", expect: "fail", leg: "read back" }, + { breaks: "read back: unit", expect: "fail", leg: "read back" }, + { breaks: "read back: source", expect: "fail", leg: "read back" }, + { breaks: "delete", expect: "fail", leg: "delete" }, + ]; + + let failures = 0; + for (const testCase of cases) { + const label = testCase.breaks ?? "nothing broken"; + const instance = await startMockInstance(testCase.breaks); + const lines = []; + const outcome = await runJourney( + { + baseUrl: instance.baseUrl, + username: SELF_TEST_CREDENTIALS.username, + password: SELF_TEST_CREDENTIALS.password, + expectedVersion: SELF_TEST_VERSION, + }, + (line) => lines.push(line), + ); + await instance.close(); + + const wanted = testCase.expect === "pass"; + const legMatches = + testCase.expect === "pass" || outcome.leg === testCase.leg; + if (outcome.ok === wanted && legMatches) { + console.log( + `self-test ok ${label.padEnd(20)} → ${outcome.ok ? "green" : `red on "${outcome.leg}"`}`, + ); + } else { + failures += 1; + console.log( + `self-test FAILED ${label.padEnd(20)} → ${outcome.ok ? "green" : `red on "${outcome.leg}"`}, expected ${testCase.expect}${testCase.leg ? ` on "${testCase.leg}"` : ""}`, + ); + for (const line of lines) console.log(` | ${line}`); + } + } + + if (failures > 0) { + console.error(`\nself-test: ${failures} case(s) behaved wrongly`); + return 1; + } + console.log(`\nself-test: ${cases.length} cases, every leg proven breakable`); + return 0; +} + +/* ------------------------------------------------------------------ */ + +function readConfig() { + const missing = []; + const read = (name) => { + const value = process.env[name]?.trim(); + if (!value) missing.push(name); + return value ?? ""; + }; + const config = { + baseUrl: read("BASE_URL").replace(/\/+$/, ""), + username: read("SYNTHETIC_USERNAME"), + password: read("SYNTHETIC_PASSWORD"), + expectedVersion: read("EXPECTED_VERSION"), + }; + if (missing.length > 0) { + console.error(`Missing environment: ${missing.join(", ")}`); + console.error( + "Usage: BASE_URL=… SYNTHETIC_USERNAME=… SYNTHETIC_PASSWORD=… EXPECTED_VERSION=… node scripts/synthetic-journey.mjs", + ); + process.exit(2); + } + return config; +} + +async function main() { + if (process.argv.includes("--self-test")) { + process.exit(await selfTest()); + } + const config = readConfig(); + console.log(`synthetic journey → ${config.baseUrl}`); + const outcome = await runJourney(config); + if (!outcome.ok) { + console.error(`\nsynthetic journey RED on "${outcome.leg}"`); + process.exit(1); + } + console.log("\nsynthetic journey green"); +} + +await main(); From 6fe18979418beeb30bb643994427ae76a9267da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:04:57 +0200 Subject: [PATCH 38/50] fix(medications): accept an Idempotency-Key on the three replayed creates The native client replays its offline outbox with the same Idempotency-Key on every attempt, and `POST /api/medications`, the side-effect log and the inventory register ran the handler again on the replay: after a lost success response the account was left with a duplicate medication, a duplicate symptom entry or a second pen in the supply count. The dedupe on `(externalSource, externalId)` only ever covered a MIRRORED create; a manually entered medication carries neither field and had nothing to collapse a retry onto. All three wrap in `withIdempotency`, the composition the batch ingest already ships, and the three operations publish the header so a client generated against the contract can tell that a retry is safe. Zod parsing and `requireRecordAuth` are untouched. An integration test drives each route twice against real Postgres and asserts what a status check cannot: one row in the table, and a replayed 201 carrying `X-Idempotent-Replay: true`. --- docs/api/openapi.yaml | 7 + .../api/medications/[id]/inventory/route.ts | 206 ++++++++-------- .../medications/[id]/side-effects/route.ts | 188 +++++++------- src/app/api/medications/route.ts | 14 +- src/lib/openapi/routes/medications/paths.ts | 6 + .../medication-write-idempotency.test.ts | 230 ++++++++++++++++++ 6 files changed, 464 insertions(+), 187 deletions(-) create mode 100644 tests/integration/medication-write-idempotency.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 72915fbc3..4da4df1da 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4834,6 +4834,7 @@ paths: schema: type: string required: true + - *a6 requestBody: required: true content: @@ -4855,6 +4856,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": *a7 "422": *a4 "429": description: More than 30 side-effect writes in the trailing minute for this ACTOR. @@ -5213,6 +5215,8 @@ paths: medications.mirror.limit_exceeded`) once the caller already holds 30 medications mirrored from the same external source; an idempotent re-post of a medication already mirrored is unaffected, and native creates are never gated. + parameters: + - *a6 requestBody: required: true content: @@ -5228,6 +5232,7 @@ paths: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 "403": *a8 + "409": *a7 "422": *a4 "429": *a5 /api/medications/layout: @@ -5563,6 +5568,7 @@ paths: schema: type: string required: true + - *a6 requestBody: required: true content: @@ -5584,6 +5590,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": *a7 "422": *a4 "429": *a5 /api/medications/{id}/inventory/{itemId}: diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index e15529cb7..16b880d7b 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -37,6 +37,7 @@ import { import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { shapeInventoryItemNotes } from "@/lib/crypto/note-cipher"; +import { withIdempotency } from "@/lib/idempotency"; type RouteParams = { params: Promise<{ id: string }> }; @@ -90,102 +91,113 @@ export const GET = apiHandler( }, ); +/** + * Wrapped in `withIdempotency`: registering a container is replayed out of the + * client's offline outbox under the same `Idempotency-Key`, and a replay after + * a lost success response would otherwise register the same pen twice and + * double the stock the low-stock notification reads. + */ export const POST = apiHandler( - async (request: NextRequest, { params }: RouteParams) => { - // v1.37.0 — MANAGE. Recording the stock the low-stock notification reads; - // that notification rings the record's own phone. - const { user, actor } = await requireRecordAuth("manage", "medications"); - const { id } = await params; - - const guard = await assertMedicationOwnership(id, user.id); - if (guard) return guard; - - // Per-caller POST rate-limit — 30/min is generous for normal pen - // registrations but cuts off the spam case. - // - // v1.37.0 — C1: the bucket keys on the ACTOR, so a manager burns their - // own allowance rather than the owner's and cannot collect a fresh one by - // switching records. - const rl = await checkRateLimit( - `medication-inventory:post:${actor.id}`, - POST_RATE_LIMIT, - POST_WINDOW_MS, - ); - if (!rl.allowed) { - return apiError("Too many requests", 429, { - headers: rateLimitHeaders(rl), - }); - } - - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = createInventoryItemSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 W6 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } - - const { - unitsTotal, - containerType, - printedExpiry, - purchasedAt, - manufacturer, - doseStrength, - notes, - } = parsed.data; - - const created = await prisma.medicationInventoryItem.create({ - // The wire field `unitsTotal` counts UNITS (tablets / ampoules / - // puffs) — v1.16.10 renamed the request field to match the - // response side. - data: buildCreateInventoryInput({ - userId: user.id, - medicationId: id, - unitsTotal, - containerType: containerType ?? "OTHER", - printedExpiry: printedExpiry ?? null, - purchasedAt: purchasedAt ?? null, - manufacturer: manufacturer ?? null, - doseStrength: doseStrength ?? null, - notes: notes ?? null, - }), - }); - - await auditLog("medication.inventory.create", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - medicationId: id, - itemId: created.id, - unitsTotal, - containerType: created.containerType, - }, - }); - - annotate({ - action: { - name: "medication.inventory.create", - entity_type: "inventory_item", - entity_id: created.id, - }, - meta: { medication_id: id }, - }); - - // A registered container changes the dose-derived stock the - // medications-list payload carries (`stockUnitsRemaining` / - // `stockDosesRemaining`), which the card and table render. Hard-evict - // the per-user medications + compliance buckets so the supply shows on - // the very next read — a mark-stale would let the `cachedSwr` list - // serve the pre-write stock for the rest of the stale window. - invalidateUserMedications(user.id, { evict: true }); - - return apiSuccess( - serializeInventoryItem(shapeInventoryItemNotes(created)), - 201, - ); - }, + withIdempotency<[NextRequest, RouteParams]>(postInventoryItem), ); + +async function postInventoryItem( + request: NextRequest, + { params }: RouteParams, +): Promise { + // v1.37.0 — MANAGE. Recording the stock the low-stock notification reads; + // that notification rings the record's own phone. + const { user, actor } = await requireRecordAuth("manage", "medications"); + const { id } = await params; + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + // Per-caller POST rate-limit — 30/min is generous for normal pen + // registrations but cuts off the spam case. + // + // v1.37.0 — C1: the bucket keys on the ACTOR, so a manager burns their + // own allowance rather than the owner's and cannot collect a fresh one by + // switching records. + const rl = await checkRateLimit( + `medication-inventory:post:${actor.id}`, + POST_RATE_LIMIT, + POST_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createInventoryItemSchema.safeParse(body); + if (!parsed.success) { + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); + } + + const { + unitsTotal, + containerType, + printedExpiry, + purchasedAt, + manufacturer, + doseStrength, + notes, + } = parsed.data; + + const created = await prisma.medicationInventoryItem.create({ + // The wire field `unitsTotal` counts UNITS (tablets / ampoules / + // puffs) — v1.16.10 renamed the request field to match the + // response side. + data: buildCreateInventoryInput({ + userId: user.id, + medicationId: id, + unitsTotal, + containerType: containerType ?? "OTHER", + printedExpiry: printedExpiry ?? null, + purchasedAt: purchasedAt ?? null, + manufacturer: manufacturer ?? null, + doseStrength: doseStrength ?? null, + notes: notes ?? null, + }), + }); + + await auditLog("medication.inventory.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + medicationId: id, + itemId: created.id, + unitsTotal, + containerType: created.containerType, + }, + }); + + annotate({ + action: { + name: "medication.inventory.create", + entity_type: "inventory_item", + entity_id: created.id, + }, + meta: { medication_id: id }, + }); + + // A registered container changes the dose-derived stock the + // medications-list payload carries (`stockUnitsRemaining` / + // `stockDosesRemaining`), which the card and table render. Hard-evict + // the per-user medications + compliance buckets so the supply shows on + // the very next read — a mark-stale would let the `cachedSwr` list + // serve the pre-write stock for the rest of the stale window. + invalidateUserMedications(user.id, { evict: true }); + + return apiSuccess( + serializeInventoryItem(shapeInventoryItemNotes(created)), + 201, + ); +} diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index b3f32e032..440184ebc 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -38,6 +38,7 @@ import { import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher"; +import { withIdempotency } from "@/lib/idempotency"; type RouteParams = { params: Promise<{ id: string }> }; @@ -96,93 +97,104 @@ export const GET = apiHandler( }, ); +/** + * Wrapped in `withIdempotency`: the client replays this write out of its + * offline outbox with the same `Idempotency-Key`, and a replay after a lost + * success response must not log the same symptom twice. Nothing else on this + * row is unique, so the wrapper is the only thing that can collapse a retry. + */ export const POST = apiHandler( - async (request: NextRequest, { params }: RouteParams) => { - // v1.36.x — a delegated write. `user` is the record the entry lands under; - // `actor` is whoever is typing, and the two differ only under a switch. - const { user, actor } = await requireRecordAuth("write", "medications"); - const { id } = await params; - - const guard = await assertMedicationOwnership(id, user.id); - if (guard) return guard; - - // Per-caller POST rate-limit — 30/min comfortably absorbs a session - // of bulk back-fill (e.g. "log yesterday's symptoms") while cutting - // off automated abuse. - // - // v1.36.x — the bucket keys on the ACTOR, not on the resolved record, and - // this is the one condition on which delegating this verb was admitted. On - // the record key a delegate could exhaust the owner's allowance and lock - // them out of their own log, and could collect a fresh one by switching to - // another record. On the actor key they burn their own, once, wherever - // they are. `medications/compliance` set the same precedent on the read - // side. `actor.id` equals `user.id` for everyone who has not switched, so - // the bucket a self-writer lands in is byte-identical to before. - const rl = await checkRateLimit( - `medication-side-effect:post:${actor.id}`, - POST_RATE_LIMIT, - POST_WINDOW_MS, - ); - if (!rl.allowed) { - return apiError("Too many requests", 429, { - headers: rateLimitHeaders(rl), - }); - } - - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = createSideEffectSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 W6 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } - - const { entry, severity, occurredAt, notes } = parsed.data; - - // v1.4.25 W21 Fix-N (code-M6) — category is derived server-side - // from the entry via the authoritative taxonomy mapping. The wire - // schema no longer accepts `category`; older clients that still - // send it now have it ignored by Zod's strict drop, and the row - // lands with the correct (entry-derived) category every time. - const category = categoryForEntry(entry); - - const created = await prisma.medicationSideEffect.create({ - data: { - userId: user.id, - medicationId: id, - category, - entry, - severity, - occurredAt: occurredAt ?? new Date(), - // Encrypt the free-text note at rest; the plaintext column stays null. - notesEncrypted: encryptNote(notes), - notes: null, - }, - }); - - await auditLog("medication.sideEffect.create", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - medicationId: id, - sideEffectId: created.id, - entry, - severity, - }, - }); - - annotate({ - action: { - name: "medication.sideEffect.create", - entity_type: "medication_side_effect", - entity_id: created.id, - }, - meta: { medication_id: id, entry, severity }, - }); - - return apiSuccess(shapeSideEffectNotes(created), 201); - }, + withIdempotency<[NextRequest, RouteParams]>(postSideEffect), ); + +async function postSideEffect( + request: NextRequest, + { params }: RouteParams, +): Promise { + // v1.36.x — a delegated write. `user` is the record the entry lands under; + // `actor` is whoever is typing, and the two differ only under a switch. + const { user, actor } = await requireRecordAuth("write", "medications"); + const { id } = await params; + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + // Per-caller POST rate-limit — 30/min comfortably absorbs a session + // of bulk back-fill (e.g. "log yesterday's symptoms") while cutting + // off automated abuse. + // + // v1.36.x — the bucket keys on the ACTOR, not on the resolved record, and + // this is the one condition on which delegating this verb was admitted. On + // the record key a delegate could exhaust the owner's allowance and lock + // them out of their own log, and could collect a fresh one by switching to + // another record. On the actor key they burn their own, once, wherever + // they are. `medications/compliance` set the same precedent on the read + // side. `actor.id` equals `user.id` for everyone who has not switched, so + // the bucket a self-writer lands in is byte-identical to before. + const rl = await checkRateLimit( + `medication-side-effect:post:${actor.id}`, + POST_RATE_LIMIT, + POST_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createSideEffectSchema.safeParse(body); + if (!parsed.success) { + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); + } + + const { entry, severity, occurredAt, notes } = parsed.data; + + // v1.4.25 W21 Fix-N (code-M6) — category is derived server-side + // from the entry via the authoritative taxonomy mapping. The wire + // schema no longer accepts `category`; older clients that still + // send it now have it ignored by Zod's strict drop, and the row + // lands with the correct (entry-derived) category every time. + const category = categoryForEntry(entry); + + const created = await prisma.medicationSideEffect.create({ + data: { + userId: user.id, + medicationId: id, + category, + entry, + severity, + occurredAt: occurredAt ?? new Date(), + // Encrypt the free-text note at rest; the plaintext column stays null. + notesEncrypted: encryptNote(notes), + notes: null, + }, + }); + + await auditLog("medication.sideEffect.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + medicationId: id, + sideEffectId: created.id, + entry, + severity, + }, + }); + + annotate({ + action: { + name: "medication.sideEffect.create", + entity_type: "medication_side_effect", + entity_id: created.id, + }, + meta: { medication_id: id, entry, severity }, + }); + + return apiSuccess(shapeSideEffectNotes(created), 201); +} diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 1c51f2860..61ca2fccd 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -23,6 +23,7 @@ import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { readMedicationsListCached } from "@/lib/medications/list-read"; import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto"; +import { withIdempotency } from "@/lib/idempotency"; import { NextRequest } from "next/server"; // v1.32.25 — blast-radius cap on externally-mirrored medications per user. @@ -103,7 +104,16 @@ async function respondWithExistingMirror( }); } -export const POST = apiHandler(async (request: NextRequest) => { +/** + * The create is wrapped in `withIdempotency` so a client replaying its offline + * outbox after a lost success response replays the first answer instead of + * minting a second medication. The `(externalSource, externalId)` dedupe below + * only ever covered a MIRRORED create; a manually entered medication carries + * neither field and had nothing to collapse a retry onto. + */ +export const POST = apiHandler(withIdempotency<[NextRequest]>(postMedication)); + +async function postMedication(request: NextRequest): Promise { // v1.36.x — a delegated write, and the asymmetric one: a delegate can ADD a // medication with its whole nested schedule, and can never edit or delete // it afterwards — `PUT /api/medications/[id]` and the DELETE beside it stay @@ -424,4 +434,4 @@ export const POST = apiHandler(async (request: NextRequest) => { }, 201, ); -}); +} diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 2ead8a4a4..bdf31f54c 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -575,6 +575,7 @@ export const medicationPaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Medications"], summary: "Log a side effect", description: @@ -585,6 +586,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: createSideEffectRequest } }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "The created entry, with its note decrypted.", @@ -912,6 +914,7 @@ export const medicationPaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Medications"], summary: "Create a medication with at least one schedule", description: @@ -921,6 +924,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: createMedicationSchema } }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Created medication with its schedules.", @@ -1220,6 +1224,7 @@ export const medicationPaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Medications"], summary: "Register a new supply container", description: @@ -1234,6 +1239,7 @@ export const medicationPaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: "Created inventory item.", diff --git a/tests/integration/medication-write-idempotency.test.ts b/tests/integration/medication-write-idempotency.test.ts new file mode 100644 index 000000000..9f3277c4a --- /dev/null +++ b/tests/integration/medication-write-idempotency.test.ts @@ -0,0 +1,230 @@ +/** + * The three medication create routes the native client replays out of its + * offline outbox, driven as routes against a real Postgres. + * + * Each case imports the shipped `POST` export and calls it twice with the same + * `Idempotency-Key` — the real `apiHandler`, the real `requireRecordAuth`, the + * real `withIdempotency`, the real Prisma writes. Nothing is reimplemented, + * because the thing under test happens ABOVE the handler body: a test that + * rebuilt the handler would prove its own copy of the wrapper works and would + * keep passing after a route dropped it. + * + * The client sends the header on every replay, so a lost success response used + * to cost the user a duplicate medication, a duplicate symptom entry, or a + * duplicate pen in the supply count. `POST /api/medications` had a dedupe of + * its own, but only for a MIRRORED create keyed on + * `(externalSource, externalId)`; a manually entered medication carries + * neither field and had nothing to collapse a retry onto — so the manual shape + * is what this file posts. + * + * Two assertions per route, and the second is the one that matters: the replay + * carries `X-Idempotent-Replay: true`, and the table holds exactly one row. A + * status-only check would pass against a handler that wrote twice and answered + * 201 twice. The medication case adds a third: a DIFFERENT key does create a + * second row, so the single row above is the key's doing and not some other + * uniqueness the fixture stumbled into. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-medication-idempotency-integration-1234567890abcdef"; +// The side-effect note is encrypted at rest on the way in. +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const USER_ID = "user-medication-idempotency"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "medication-idempotency", + email: "medication-idempotency@example.test", + timezone: "UTC", + }, + }); + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function post(path: string, body: unknown, idempotencyKey: string) { + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + "idempotency-key": idempotencyKey, + }, + body: JSON.stringify(body), + }); +} + +/** A route whose params shape is its own business — see `sharing-delegable-routes`. */ +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +/** A medication to hang the side-effect and inventory writes off. */ +async function seedMedication(): Promise { + const medication = await getPrismaClient().medication.create({ + data: { userId: USER_ID, name: "Fixture", dose: "1 tab" }, + }); + return medication.id; +} + +const MANUAL_MEDICATION = { + name: "Manually entered", + dose: "10 mg", + schedules: [ + { windowStart: "08:00", windowEnd: "08:30", timesOfDay: ["08:00"] }, + ], +}; + +describe("POST /api/medications — replayed manual create", () => { + it("replays the first response and creates exactly one medication", async () => { + const { POST } = await import("@/app/api/medications/route"); + + const first = await POST( + post("/api/medications", MANUAL_MEDICATION, "outbox-medication-1"), + ); + expect(first.status).toBe(201); + const firstBody = await first.json(); + + const replay = await POST( + post("/api/medications", MANUAL_MEDICATION, "outbox-medication-1"), + ); + expect(replay.status).toBe(201); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(await replay.json()).toEqual(firstBody); + + const rows = await getPrismaClient().medication.findMany({ + where: { userId: USER_ID }, + }); + expect(rows).toHaveLength(1); + }); + + it("still creates a second medication under a different key", async () => { + // The control on the case above: without it, a single row would also be + // the reading if the create had silently failed the second time for an + // unrelated reason. + const { POST } = await import("@/app/api/medications/route"); + + await POST( + post("/api/medications", MANUAL_MEDICATION, "outbox-medication-a"), + ); + const second = await POST( + post("/api/medications", MANUAL_MEDICATION, "outbox-medication-b"), + ); + expect(second.status).toBe(201); + expect(second.headers.get("X-Idempotent-Replay")).toBeNull(); + + const rows = await getPrismaClient().medication.findMany({ + where: { userId: USER_ID }, + }); + expect(rows).toHaveLength(2); + }); +}); + +describe("POST /api/medications/[id]/side-effects — replayed entry", () => { + it("replays the first response and logs exactly one entry", async () => { + const medicationId = await seedMedication(); + const { POST } = + await import("@/app/api/medications/[id]/side-effects/route"); + const body = { entry: "NAUSEA", severity: 3, notes: "after breakfast" }; + + const first = await (POST as Handler)( + post( + `/api/medications/${medicationId}/side-effects`, + body, + "outbox-side-effect-1", + ), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(first.status).toBe(201); + const firstBody = await first.json(); + + const replay = await (POST as Handler)( + post( + `/api/medications/${medicationId}/side-effects`, + body, + "outbox-side-effect-1", + ), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(replay.status).toBe(201); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(await replay.json()).toEqual(firstBody); + + const rows = await getPrismaClient().medicationSideEffect.findMany({ + where: { userId: USER_ID, medicationId }, + }); + expect(rows).toHaveLength(1); + }); +}); + +describe("POST /api/medications/[id]/inventory — replayed container", () => { + it("replays the first response and registers exactly one container", async () => { + const medicationId = await seedMedication(); + const { POST } = await import("@/app/api/medications/[id]/inventory/route"); + const body = { unitsTotal: 30, containerType: "BLISTER" }; + + const first = await (POST as Handler)( + post( + `/api/medications/${medicationId}/inventory`, + body, + "outbox-inventory-1", + ), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(first.status).toBe(201); + const firstBody = await first.json(); + + const replay = await (POST as Handler)( + post( + `/api/medications/${medicationId}/inventory`, + body, + "outbox-inventory-1", + ), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(replay.status).toBe(201); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(await replay.json()).toEqual(firstBody); + + const rows = await getPrismaClient().medicationInventoryItem.findMany({ + where: { userId: USER_ID, medicationId }, + }); + expect(rows).toHaveLength(1); + }); +}); From 8702d85c9db6a5e4c475d2cc09a8f48cacc16d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:05:03 +0200 Subject: [PATCH 39/50] test(notifications): pin aps.mutable-content on the medication reminder The client's notification service extension replaces the medication name on the lock screen, and iOS routes a payload through an extension only when the alert carries `mutable-content`. The dispatcher has set the flag on every alert it builds since the category work for the v0.5.4 client, but the only assertion on it sat inside a category test, where dropping it would have failed nothing and the name would have quietly reappeared on the lock screen of a device that asked for it to be hidden. Two cases now say it out loud: the medication reminder carries the flag, and so does another event type, because that is the shipped behaviour and narrowing it would take a capability away from the client rather than tidy something up. --- .../senders/__tests__/apns.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index 7d567c230..c7c68b849 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -630,6 +630,49 @@ describe("sendViaApns — dispatcher fan-out", () => { expect(note.payload.eventType).toBe("MEDICATION_REMINDER"); }); + // The iOS Notification Service Extension that replaces the medication name + // on the lock screen runs only when the payload carries + // `aps.mutable-content = 1`. The category test above happens to touch the + // flag; this pins it on its own, because losing it would not fail a + // category assertion and the name would silently reappear on the lock + // screen of a device that asked for it to be hidden. + it("marks a MEDICATION_REMINDER mutable so the client extension can rewrite the alert", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEDICATION_REMINDER", + metadata: { medicationId: "med-1" }, + }); + + expect(sendMock.mock.calls[0][0].mutableContent).toBe(true); + }); + + // Not a medication-only flag: `sendViaApns` sets it on every alert it + // builds, so an extension may enrich whichever event types the client + // registers for. Recorded as an assertion rather than left to be assumed + // from the source, because narrowing it to one branch would take a + // capability away from the client and should read as the contract change it + // is, not as a tidy-up. + it("marks every alert event type mutable, not only the medication branch", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEASUREMENT_REMINDER", + }); + + expect(sendMock.mock.calls[0][0].mutableContent).toBe(true); + }); + it("omits action metadata and categories for a managed Guardian", async () => { vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, From 1ab2e1b5245487266cd1e94c1cecea9743738574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:09:39 +0200 Subject: [PATCH 40/50] chore: regenerate the OpenAPI document and list the page-side seed after integration --- src/__tests__/e2e-measurement-gating-guard.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/__tests__/e2e-measurement-gating-guard.test.ts b/src/__tests__/e2e-measurement-gating-guard.test.ts index 6f4970f75..407cc7728 100644 --- a/src/__tests__/e2e-measurement-gating-guard.test.ts +++ b/src/__tests__/e2e-measurement-gating-guard.test.ts @@ -34,6 +34,11 @@ const E2E_DIR = resolve(__dirname, "../../e2e"); * wrong question and should be rethought rather than extended. */ const ALLOWLIST: ReadonlyArray<{ file: string; test: string; why: string }> = [ + { + file: "vaccinations.spec.ts", + test: "the list and the capture form carry no serious accessibility violations", + why: "The evaluate after the goto reads no DOM: it issues the seed POST from the page's own origin so the row is written over the browser's socket rather than the runner's shared keep-alive agent, which handed POSTs to sockets the server had already closed. The scan that follows is gated on the reloaded list.", + }, { file: "ipad-viewport.spec.ts", test: "no horizontal overflow and full-width content on", From 742eeaad3ca49ddb7a70f2f5ef19928a01dbec99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:25:01 +0200 Subject: [PATCH 41/50] fix(ops): refuse a synthetic-journey target that is not ours S1. The journey signs in with a real account and POSTs that account's password to whatever BASE_URL names, and the workflow took BASE_URL as free text. Anyone with repo write, or a leaked token, could dispatch the run at a collector of their own and keep a working credential; secret masking guards the log, never the egress. The host now has to be on a short const allowlist in the script, checked before anything is sent, with exit 2 and a named refusal otherwise. The workflow input is a choice between the two hosted instances, so the form cannot offer a third; the script's own check still stands behind it for a dispatch that goes round the form. --self-test covers the refusal, the prefix lookalike a startsWith check would have waved through included. --- .github/workflows/synthetic-journey.yml | 16 ++++- scripts/synthetic-journey.mjs | 81 ++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/.github/workflows/synthetic-journey.yml b/.github/workflows/synthetic-journey.yml index 6354bdcc4..2d096f9b0 100644 --- a/.github/workflows/synthetic-journey.yml +++ b/.github/workflows/synthetic-journey.yml @@ -21,10 +21,19 @@ name: Synthetic journey on: workflow_dispatch: inputs: + # A closed list, not free text. The dispatcher chooses where the probe + # account's password is POSTed, so the choice is between the hosted + # instances and nothing else. The script re-checks the host against its + # own const allowlist and exits 2 on anything else, so a dispatch that + # goes round this form still cannot reach a stranger's collector. base_url: - description: Instance to walk, e.g. https://review.healthlog.dev + description: Instance to walk required: true - type: string + type: choice + default: https://review.healthlog.dev + options: + - https://review.healthlog.dev + - https://healthlog.bombeck.io expected_version: description: Version the instance must serve, e.g. v1.38.13 required: true @@ -53,7 +62,8 @@ jobs: run: node scripts/synthetic-journey.mjs --self-test # Every input and secret rides an env var — never interpolated into - # the shell command. + # the shell command. The script refuses a `BASE_URL` whose host is off + # its allowlist before it sends the password anywhere. - name: Walk the journey env: BASE_URL: ${{ inputs.base_url }} diff --git a/scripts/synthetic-journey.mjs b/scripts/synthetic-journey.mjs index e59d7fa21..eae3442d4 100644 --- a/scripts/synthetic-journey.mjs +++ b/scripts/synthetic-journey.mjs @@ -26,12 +26,16 @@ * the row's id, value, unit and source. * 5. delete — `DELETE /api/measurements/{id}` so the account stays clean. * + * `BASE_URL` decides where the probe account's password is POSTed, so it is + * checked against a closed in-repo host allowlist and refused with exit 2 + * otherwise. + * * The first failing leg ends the run with a non-zero exit, the response * status and a short body excerpt. Anything token-shaped in that excerpt * is redacted before it reaches the log. * * Usage: - * BASE_URL=https://review.example.test \ + * BASE_URL=https://review.healthlog.dev \ * SYNTHETIC_USERNAME=… SYNTHETIC_PASSWORD=… EXPECTED_VERSION=v1.38.13 \ * node scripts/synthetic-journey.mjs * @@ -60,6 +64,36 @@ const BODY_EXCERPT_LIMIT = 400; /** The reading the journey writes. Distinctive, plausible, and short-lived. */ const PROBE = { type: "WEIGHT", value: 77.7, unit: "kg", source: "MANUAL" }; +/** + * The journey signs in with a real account and POSTs that account's password + * to whatever `BASE_URL` names, so `BASE_URL` alone decides where the + * password goes. Anyone able to name the host walks off with a working + * credential; masking guards the log, not the egress. The allowlist is the + * defence, and it is deliberately a const: it changes by a commit and a + * review, never by a form field. + */ +const ALLOWED_HOSTS = [ + "review.healthlog.dev", + "healthlog.bombeck.io", + "localhost", + "127.0.0.1", +]; + +/** + * `null` when the host may receive the probe credentials, otherwise the line + * the caller prints before exiting 2. + */ +function hostRefusal(baseUrl) { + let host; + try { + host = new URL(baseUrl).hostname; + } catch { + return `BASE_URL is not a URL: ${JSON.stringify(baseUrl)}`; + } + if (ALLOWED_HOSTS.includes(host)) return null; + return `BASE_URL host ${JSON.stringify(host)} is not on the allowlist — the probe password is only ever sent to ${ALLOWED_HOSTS.join(", ")}`; +} + /** * Patterns that must never reach a log line. The first three are this * project's own token prefixes plus the OpenAI-style key shape the @@ -488,6 +522,40 @@ function startMockInstance(breaks) { }); } +/** + * The allowlist is the only thing standing between a dispatcher and the + * probe account's password, so prove it refuses — including the prefix + * lookalike, which a `startsWith` check would have waved through. + */ +function selfTestHostAllowlist() { + const cases = [ + { url: "https://review.healthlog.dev", refused: false }, + { url: "https://healthlog.bombeck.io/", refused: false }, + { url: "http://localhost:3000", refused: false }, + { url: "http://127.0.0.1:3000", refused: false }, + { url: "https://healthlog.bombeck.io.example.test", refused: true }, + { url: "https://collector.example.test", refused: true }, + { url: "not-a-url", refused: true }, + ]; + + let failures = 0; + for (const testCase of cases) { + const refusal = hostRefusal(testCase.url); + const label = `host ${testCase.url}`; + if (Boolean(refusal) === testCase.refused) { + console.log( + `self-test ok ${label.padEnd(44)} → ${refusal ? "refused" : "allowed"}`, + ); + } else { + failures += 1; + console.log( + `self-test FAILED ${label.padEnd(44)} → ${refusal ? "refused" : "allowed"}, expected ${testCase.refused ? "refused" : "allowed"}`, + ); + } + } + return failures; +} + async function selfTest() { const cases = [ { breaks: null, expect: "pass" }, @@ -501,7 +569,7 @@ async function selfTest() { { breaks: "delete", expect: "fail", leg: "delete" }, ]; - let failures = 0; + let failures = selfTestHostAllowlist(); for (const testCase of cases) { const label = testCase.breaks ?? "nothing broken"; const instance = await startMockInstance(testCase.breaks); @@ -537,7 +605,9 @@ async function selfTest() { console.error(`\nself-test: ${failures} case(s) behaved wrongly`); return 1; } - console.log(`\nself-test: ${cases.length} cases, every leg proven breakable`); + console.log( + `\nself-test: ${cases.length} journey cases, every leg proven breakable; host allowlist proven`, + ); return 0; } @@ -563,6 +633,11 @@ function readConfig() { ); process.exit(2); } + const refusal = hostRefusal(config.baseUrl); + if (refusal) { + console.error(refusal); + process.exit(2); + } return config; } From 25cb79ecfc4b743c6fb1a9c4101ccf5b81815bd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:26:04 +0200 Subject: [PATCH 42/50] fix(ops): let no failed journey leg leave a reading behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S2. The legs ran in one loop that returned on the first red one, so a failed read back never reached the delete and the probe account kept the row. Worse, the id was assigned only after the write's await returned: a POST that timed out once the server had already committed left a row the script could not even name. Legs 3 to 5 now sit in a try/finally. The finally sweeps whatever the delete leg did not, and when there is no id it asks the instance for the row by a marker the write puts in `notes`, unique per run and known before the request goes out. The sweep never throws — the red leg is the verdict, and it says out loud what it could not remove. --self-test asserts the mock holds no rows after every case that got as far as the write, so a red read that skipped the delete is caught. --- scripts/synthetic-journey.mjs | 187 +++++++++++++++++++++++++++------- 1 file changed, 152 insertions(+), 35 deletions(-) diff --git a/scripts/synthetic-journey.mjs b/scripts/synthetic-journey.mjs index eae3442d4..dd28e5e65 100644 --- a/scripts/synthetic-journey.mjs +++ b/scripts/synthetic-journey.mjs @@ -26,6 +26,13 @@ * the row's id, value, unit and source. * 5. delete — `DELETE /api/measurements/{id}` so the account stays clean. * + * Legs 3 to 5 run inside a `try`/`finally`: from the write on, the run owns + * a row on a real account, and a red read leg must not leave it behind. The + * `finally` sweeps whatever the delete leg did not — including a row whose + * id the write leg never learned, because the response was lost after the + * server had committed. That row is found again by a marker the write puts + * in `notes`, unique per run. + * * `BASE_URL` decides where the probe account's password is POSTed, so it is * checked against a closed in-repo host allowlist and refused with exit 2 * otherwise. @@ -279,17 +286,19 @@ async function signIn(baseUrl, jar, username, password) { return { detail: "session cookie held" }; } -async function writeReading(baseUrl, jar) { +async function writeReading(baseUrl, jar, run) { const measuredAt = new Date().toISOString(); const result = await call(baseUrl, "/api/measurements", { method: "POST", jar, - headers: { "idempotency-key": randomUUID() }, + headers: { "idempotency-key": run.idempotencyKey }, body: { type: PROBE.type, value: PROBE.value, measuredAt, source: PROBE.source, + // The one handle on this row that exists before the response does. + notes: run.marker, }, }); expectStatus(result, 201, "write"); @@ -303,7 +312,7 @@ async function writeReading(baseUrl, jar) { return { id: created.id, measuredAt, detail: `wrote ${created.id}` }; } -async function readBack(baseUrl, jar, written) { +async function readBack(baseUrl, jar, writtenId) { const result = await call( baseUrl, "/api/measurements?type=WEIGHT&limit=1&sortBy=measuredAt&sortDir=desc", @@ -318,15 +327,15 @@ async function readBack(baseUrl, jar, written) { }); } const row = rows[0]; - expectValue(row.id, written.id, "read back: id", result); + expectValue(row.id, writtenId, "read back: id", result); expectValue(row.value, PROBE.value, "read back: value", result); expectValue(row.unit, PROBE.unit, "read back: unit", result); expectValue(row.source, PROBE.source, "read back: source", result); return { detail: `${row.value} ${row.unit} (${row.source})` }; } -async function removeReading(baseUrl, jar, written) { - const result = await call(baseUrl, `/api/measurements/${written.id}`, { +async function removeReading(baseUrl, jar, writtenId) { + const result = await call(baseUrl, `/api/measurements/${writtenId}`, { method: "DELETE", jar, }); @@ -334,6 +343,46 @@ async function removeReading(baseUrl, jar, written) { return { detail: "account left clean" }; } +/** + * The id the write leg never learned. A POST that times out after the server + * committed leaves a row this run owns and cannot name, so ask the instance + * for it: the marker rides in `notes`, is unique per run, and comes back + * decrypted on the list. + */ +async function findLeftover(baseUrl, jar, marker) { + const result = await call( + baseUrl, + "/api/measurements?type=WEIGHT&limit=20&sortBy=measuredAt&sortDir=desc", + { jar }, + ); + if (result.status !== 200) return null; + const rows = result.json?.data?.measurements; + if (!Array.isArray(rows)) return null; + return rows.find((row) => row?.notes === marker)?.id ?? null; +} + +/** + * Best effort, never throws: a red leg is already the run's verdict, and a + * sweep that threw would replace it. It says out loud what it could not + * remove so the maintainer can. + */ +async function sweep(baseUrl, jar, writtenId, marker, log) { + let id = writtenId; + if (!id) { + id = await findLeftover(baseUrl, jar, marker).catch(() => null); + } + if (!id) return; + const result = await call(baseUrl, `/api/measurements/${id}`, { + method: "DELETE", + jar, + }).catch(() => null); + log( + result?.status === 200 + ? `sweep removed ${id} after a failed leg` + : `sweep could not remove ${id} — delete it by hand`, + ); +} + /** * Run the journey. Returns `{ ok: true }` or `{ ok: false, leg, error }` — * the caller decides what an exit code is, which is what lets the self-test @@ -342,6 +391,13 @@ async function removeReading(baseUrl, jar, written) { async function runJourney(config, log = console.log) { const { baseUrl, username, password, expectedVersion } = config; const jar = createCookieJar(); + // Both known BEFORE the write, so a write whose response never arrives is + // still recoverable: the key folds into the server's idempotency cell, the + // marker into the row's own `notes`. + const run = { + idempotencyKey: randomUUID(), + marker: `synthetic-journey ${randomUUID()}`, + }; const warmStarted = performance.now(); await warm(baseUrl, jar); @@ -349,33 +405,19 @@ async function runJourney(config, log = console.log) { `warm ${Math.round(performance.now() - warmStarted)} ms (discarded)`, ); - let written = null; - const legs = [ - { - name: "version", - run: () => assertVersion(baseUrl, jar, expectedVersion), - }, - { name: "sign in", run: () => signIn(baseUrl, jar, username, password) }, - { - name: "write", - run: async () => (written = await writeReading(baseUrl, jar)), - }, - { name: "read back", run: () => readBack(baseUrl, jar, written) }, - { name: "delete", run: () => removeReading(baseUrl, jar, written) }, - ]; - - for (const leg of legs) { + const runLeg = async (name, execute) => { const started = performance.now(); try { - const outcome = await leg.run(); + const outcome = await execute(); const elapsed = Math.round(performance.now() - started); log( - `ok ${leg.name.padEnd(10)} ${String(elapsed).padStart(6)} ms ${outcome?.detail ?? ""}`.trimEnd(), + `ok ${name.padEnd(10)} ${String(elapsed).padStart(6)} ms ${outcome?.detail ?? ""}`.trimEnd(), ); + return { ok: true }; } catch (error) { const elapsed = Math.round(performance.now() - started); log( - `FAILED ${leg.name.padEnd(10)} ${String(elapsed).padStart(6)} ms ${redact(error.message)}`, + `FAILED ${name.padEnd(10)} ${String(elapsed).padStart(6)} ms ${redact(error.message)}`, ); if (error instanceof LegFailure) { if (error.status !== null) log(` status ${error.status}`); @@ -383,8 +425,48 @@ async function runJourney(config, log = console.log) { } else { log(` ${redact(error.stack ?? "")}`); } - return { ok: false, leg: leg.name, error }; + return { ok: false, leg: name, error }; } + }; + + for (const [name, execute] of [ + ["version", () => assertVersion(baseUrl, jar, expectedVersion)], + ["sign in", () => signIn(baseUrl, jar, username, password)], + ]) { + const result = await runLeg(name, execute); + if (!result.ok) return result; + } + + // From the write on, the run owns a row on somebody's real account. A red + // read leg is not a reason to leave it there, so the cleanup sits in a + // `finally` and not in a leg that a `return` can skip. + let writtenId = null; + let removed = false; + try { + for (const [name, execute] of [ + [ + "write", + async () => { + const written = await writeReading(baseUrl, jar, run); + writtenId = written.id; + return written; + }, + ], + ["read back", () => readBack(baseUrl, jar, writtenId)], + [ + "delete", + async () => { + const outcome = await removeReading(baseUrl, jar, writtenId); + removed = true; + return outcome; + }, + ], + ]) { + const result = await runLeg(name, execute); + if (!result.ok) return result; + } + } finally { + if (!removed) await sweep(baseUrl, jar, writtenId, run.marker, log); } return { ok: true }; } @@ -470,8 +552,14 @@ function startMockInstance(breaks) { unit: PROBE.unit, source: parsed.source, measuredAt: parsed.measuredAt, + notes: parsed.notes ?? null, }; rows.set(row.id, row); + // The case the sweep exists for: the server committed and the + // caller never learned the id. Only the marker leads back here. + if (breaks === "write: committed") { + return send(500, { data: null, error: "gateway timed out" }); + } send(201, { data: row, error: null }); }); return; @@ -516,6 +604,7 @@ function startMockInstance(breaks) { const { port } = server.address(); resolve({ baseUrl: `http://127.0.0.1:${port}`, + rows, close: () => new Promise((done) => server.close(done)), }); }); @@ -558,14 +647,40 @@ function selfTestHostAllowlist() { async function selfTest() { const cases = [ - { breaks: null, expect: "pass" }, + { breaks: null, expect: "pass", clean: true }, { breaks: "version", expect: "fail", leg: "version" }, { breaks: "sign in", expect: "fail", leg: "sign in" }, - { breaks: "write", expect: "fail", leg: "write" }, - { breaks: "read back: missing", expect: "fail", leg: "read back" }, - { breaks: "read back: value", expect: "fail", leg: "read back" }, - { breaks: "read back: unit", expect: "fail", leg: "read back" }, - { breaks: "read back: source", expect: "fail", leg: "read back" }, + { breaks: "write", expect: "fail", leg: "write", clean: true }, + // The write committed and the response was lost: no id, and the sweep + // has to find the row again by its marker. + { breaks: "write: committed", expect: "fail", leg: "write", clean: true }, + // The four read cases are also the proof that a red read still deletes: + // `clean` is asserted after the run, and only the `finally` can satisfy + // it once the delete leg has been skipped. + { + breaks: "read back: missing", + expect: "fail", + leg: "read back", + clean: true, + }, + { + breaks: "read back: value", + expect: "fail", + leg: "read back", + clean: true, + }, + { + breaks: "read back: unit", + expect: "fail", + leg: "read back", + clean: true, + }, + { + breaks: "read back: source", + expect: "fail", + leg: "read back", + clean: true, + }, { breaks: "delete", expect: "fail", leg: "delete" }, ]; @@ -583,19 +698,21 @@ async function selfTest() { }, (line) => lines.push(line), ); + const leftBehind = instance.rows.size; await instance.close(); const wanted = testCase.expect === "pass"; const legMatches = testCase.expect === "pass" || outcome.leg === testCase.leg; - if (outcome.ok === wanted && legMatches) { + const cleanEnough = !testCase.clean || leftBehind === 0; + if (outcome.ok === wanted && legMatches && cleanEnough) { console.log( - `self-test ok ${label.padEnd(20)} → ${outcome.ok ? "green" : `red on "${outcome.leg}"`}`, + `self-test ok ${label.padEnd(44)} → ${outcome.ok ? "green" : `red on "${outcome.leg}"`}${testCase.clean ? ", account clean" : ""}`, ); } else { failures += 1; console.log( - `self-test FAILED ${label.padEnd(20)} → ${outcome.ok ? "green" : `red on "${outcome.leg}"`}, expected ${testCase.expect}${testCase.leg ? ` on "${testCase.leg}"` : ""}`, + `self-test FAILED ${label.padEnd(44)} → ${outcome.ok ? "green" : `red on "${outcome.leg}"`}${cleanEnough ? "" : `, ${leftBehind} row(s) left behind`}, expected ${testCase.expect}${testCase.leg ? ` on "${testCase.leg}"` : ""}`, ); for (const line of lines) console.log(` | ${line}`); } From 3ba2fe3b8278e14969af3d677ea88e126fceb3e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:26:24 +0200 Subject: [PATCH 43/50] fix(ops): prove the journey's reading is gone, not just accepted Low, review 2. The delete leg asserted HTTP 200 and printed "account left clean". An instance that answers 200 and keeps the row would have printed the same line, which is the failure mode the journey exists to catch. The leg now reads the id back and requires a 404. The self-test carries a mock that answers 200 without deleting; with the read-back removed that case goes green, which is the proof the check can fail. --- scripts/synthetic-journey.mjs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/scripts/synthetic-journey.mjs b/scripts/synthetic-journey.mjs index dd28e5e65..d5ddfb732 100644 --- a/scripts/synthetic-journey.mjs +++ b/scripts/synthetic-journey.mjs @@ -24,7 +24,9 @@ * 3. write — `POST /api/measurements` with an `Idempotency-Key`. * 4. read — `GET /api/measurements?type=WEIGHT&limit=1` and assert * the row's id, value, unit and source. - * 5. delete — `DELETE /api/measurements/{id}` so the account stays clean. + * 5. delete — `DELETE /api/measurements/{id}`, then a direct `GET` on + * the same id that must 404, so the account stays clean and + * the leg proves it rather than trusting a 200. * * Legs 3 to 5 run inside a `try`/`finally`: from the write on, the run owns * a row on a real account, and a red read leg must not leave it behind. The @@ -340,6 +342,16 @@ async function removeReading(baseUrl, jar, writtenId) { jar, }); expectStatus(result, 200, "delete"); + // A 200 is the instance's claim, not the outcome. An instance that answers + // 200 and keeps the row would have printed "account left clean" — so read + // the id back and require the 404. + const gone = await call(baseUrl, `/api/measurements/${writtenId}`, { jar }); + if (gone.status !== 404) { + throw new LegFailure( + `delete: ${writtenId} still reads back after a 200 delete`, + { status: gone.status, body: gone.text }, + ); + } return { detail: "account left clean" }; } @@ -592,10 +604,26 @@ function startMockInstance(breaks) { if (breaks === "delete") { return send(404, { data: null, error: "Measurement not found" }); } + // 200 and the row still there — the status alone cannot catch this, + // only the read-back the delete leg does afterwards. + if (breaks === "delete: kept") { + return send(200, { data: { success: true }, error: null }); + } rows.delete(url.pathname.split("/").pop()); return send(200, { data: { success: true }, error: null }); } + if ( + url.pathname.startsWith("/api/measurements/") && + request.method === "GET" + ) { + const row = rows.get(url.pathname.split("/").pop()); + if (!row) { + return send(404, { data: null, error: "Measurement not found" }); + } + return send(200, { data: row, error: null }); + } + send(404, { data: null, error: "Not found" }); }); @@ -682,6 +710,7 @@ async function selfTest() { clean: true, }, { breaks: "delete", expect: "fail", leg: "delete" }, + { breaks: "delete: kept", expect: "fail", leg: "delete" }, ]; let failures = selfTestHostAllowlist(); From 79cb08efc7e61372a0e703498bd6b544941e912f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:26:40 +0200 Subject: [PATCH 44/50] test(ops): prove the journey's redactor on a key-shaped body Low, review 2. The self-test walked every leg but never once asked whether the redactor actually holds. The excerpt path is the only thing between a token in a failure body and the run log, so it gets its own case: a body carrying this project's own key prefix must come back with the token replaced. --- scripts/synthetic-journey.mjs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/synthetic-journey.mjs b/scripts/synthetic-journey.mjs index d5ddfb732..e70a5c4db 100644 --- a/scripts/synthetic-journey.mjs +++ b/scripts/synthetic-journey.mjs @@ -673,6 +673,22 @@ function selfTestHostAllowlist() { return failures; } +/** + * The excerpt path is what keeps a token out of a failure log. Prove it + * once, on a body shaped like this project's own key prefix. + */ +function selfTestRedaction() { + const shaped = `hlk_${"a".repeat(24)}`; + const redacted = excerpt(`{"error":"bad token","token":"${shaped}"}`); + const label = "redaction"; + if (redacted.includes("hlk_") || !redacted.includes("[redacted]")) { + console.log(`self-test FAILED ${label.padEnd(44)} → ${redacted}`); + return 1; + } + console.log(`self-test ok ${label.padEnd(44)} → ${redacted}`); + return 0; +} + async function selfTest() { const cases = [ { breaks: null, expect: "pass", clean: true }, @@ -713,7 +729,7 @@ async function selfTest() { { breaks: "delete: kept", expect: "fail", leg: "delete" }, ]; - let failures = selfTestHostAllowlist(); + let failures = selfTestHostAllowlist() + selfTestRedaction(); for (const testCase of cases) { const label = testCase.breaks ?? "nothing broken"; const instance = await startMockInstance(testCase.breaks); @@ -752,7 +768,7 @@ async function selfTest() { return 1; } console.log( - `\nself-test: ${cases.length} journey cases, every leg proven breakable; host allowlist proven`, + `\nself-test: ${cases.length} journey cases, every leg proven breakable; host allowlist and redaction proven`, ); return 0; } From e4333f8f71adca218430ecaa1f33caf292d01133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:29:08 +0200 Subject: [PATCH 45/50] docs(api): say which unit the batch route reads a sample in U1. The new `unit` description on the batch entry said `value` is read in the unit HealthLog stores the identifier in. Sixteen mappings disagree: `convertToDbUnit` runs on every entry, and a waist circumference arrives in metres to be stored in centimetres, an oxygen saturation and a body fat arrive as a fraction to be stored as a percent. A client generated against that sentence sends 98 and stores 9800. The description now says what happens: `value` is read in Apple's own HKUnit for the identifier, HealthLog applies its storage scaling afterwards, and `unit` is recorded for audit rather than validated against. Two worked examples, because the percent-shaped ones are the pair that bites. YAML regenerated. --- docs/api/openapi.yaml | 9 ++++++--- src/lib/openapi/routes/measurements.ts | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2eed38ee6..4fa4d8387 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -21350,9 +21350,12 @@ components: type: string minLength: 1 maxLength: 60 - description: "Apple's `HKUnit` string for `value`, captured for audit. The server does NOT convert from it: `value` is - read in the unit HealthLog stores the identifier in (`m` for walking distance, `kg` for body mass, `kcal` - for energy, `degC` for temperature). Send the sample in that unit." + description: "Apple's `HKUnit` string for `value`, captured for audit: the server records it and never validates `value` + against it. `value` is read in Apple's own `HKUnit` for the identifier — `m` for a length, `kg` for body + mass, `kcal` for energy, `degC` for temperature, a `fraction` for the percent-shaped ones (oxygen + saturation, body fat) — and HealthLog applies its storage scaling after that. A waist circumference sent as + `0.86` in `m` is stored as `86` `cm`; an oxygen saturation sent as `0.98` in `fraction` is stored as `98` + `%`. Send the sample in Apple's unit, not in HealthLog's." startDate: type: string format: date-time diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 1f28b049b..f138557cb 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -58,7 +58,7 @@ const batchEntrySchema = z .min(1) .max(60) .describe( - "Apple's `HKUnit` string for `value`, captured for audit. The server does NOT convert from it: `value` is read in the unit HealthLog stores the identifier in (`m` for walking distance, `kg` for body mass, `kcal` for energy, `degC` for temperature). Send the sample in that unit.", + "Apple's `HKUnit` string for `value`, captured for audit: the server records it and never validates `value` against it. `value` is read in Apple's own `HKUnit` for the identifier — `m` for a length, `kg` for body mass, `kcal` for energy, `degC` for temperature, a `fraction` for the percent-shaped ones (oxygen saturation, body fat) — and HealthLog applies its storage scaling after that. A waist circumference sent as `0.86` in `m` is stored as `86` `cm`; an oxygen saturation sent as `0.98` in `fraction` is stored as `98` `%`. Send the sample in Apple's unit, not in HealthLog's.", ), startDate: z.iso.datetime({ offset: true }), endDate: z.iso.datetime({ offset: true }), From c3b5585fd68276503dbb40237109dcee7abd6ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:30:26 +0200 Subject: [PATCH 46/50] fix(ops): stop the distance dry run promising work it will refuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Low, review 2. An account with one out-of-range candidate is refused whole, rows and audit row included, but the dry run counted its rows into "N row(s) would be repaired" — so the number an operator read before --apply was larger than the number --apply could ever produce. The refused accounts are now counted and reported separately, and the per-account line says its rows are not in the total. The refusal itself told the operator to check the Health app "before forcing anything". There is no --force and never was. It now names the only route that exists: re-run with the matching --unit if the archive really was in another one. --- scripts/repair-apple-health-distance.ts | 20 ++++++++++++++----- .../repair-apple-health-distance.ts | 5 +++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/scripts/repair-apple-health-distance.ts b/scripts/repair-apple-health-distance.ts index be77a3b8f..1843f5362 100644 --- a/scripts/repair-apple-health-distance.ts +++ b/scripts/repair-apple-health-distance.ts @@ -84,6 +84,7 @@ async function main(): Promise { let totalUpdated = 0; let totalSkipped = 0; let refused = 0; + let wouldRefuse = 0; let rollupsPending = 0; for (const plan of plans) { @@ -104,7 +105,11 @@ async function main(): Promise { continue; } - totalRepairable += plan.repairable.length; + // An account with one out-of-range candidate is refused whole, so its + // rows are not "would be repaired" — counting them promised work the + // --apply run then declined to do. + if (plan.outOfRange.length > 0) wouldRefuse += 1; + else totalRepairable += plan.repairable.length; const first = plan.repairable[0]; const last = plan.repairable[plan.repairable.length - 1]; console.log( @@ -130,7 +135,8 @@ async function main(): Promise { } if (plan.outOfRange.length > 0) { console.log( - " ! this account will be REFUSED — see the reason under --apply", + " ! this account will be REFUSED whole — its rows are not in " + + "the count below; see the reason under --apply", ); } @@ -166,9 +172,13 @@ async function main(): Promise { apply ? `\nDone. ${totalUpdated} row(s) repaired, ${totalSkipped} skipped, ` + `${refused} account(s) refused.` - : `\nDry run: ${totalRepairable} row(s) would be repaired. ` + - "Read the worked examples above and decide before re-running with " + - "--apply.", + : `\nDry run: ${totalRepairable} row(s) would be repaired` + + (wouldRefuse > 0 + ? `, ${wouldRefuse} account(s) would be refused (their rows are ` + + "not in that number)" + : "") + + ". Read the worked examples above and decide before re-running " + + "with --apply.", ); if (rollupsPending > 0) { diff --git a/src/lib/measurements/repair-apple-health-distance.ts b/src/lib/measurements/repair-apple-health-distance.ts index bd2947586..19cc9364c 100644 --- a/src/lib/measurements/repair-apple-health-distance.ts +++ b/src/lib/measurements/repair-apple-health-distance.ts @@ -259,8 +259,9 @@ function outOfRangeRefusal(plan: AccountRepairPlan, factor: number): string { `leave the plausible range after x${factor}, so these rows are not the ` + "1000x class — the account was most likely re-imported on the fixed " + "build, or its archive was never in the affected unit. Nothing written. " + - "Check the dry run's worked examples against the account's Health app " + - "before forcing anything." + "There is no override: check the dry run's worked examples against the " + + "account's Health app, and if the archive really was in another unit, " + + "re-run with the matching --unit." ); } From a4909d3e0064f982922c8b2a4378ff94e7b01845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:31:17 +0200 Subject: [PATCH 47/50] docs(env): say exactly which object the off-host worker deletes E1. Two halves of one correction. `.env.example` still said backup objects older than BACKUP_RETENTION_DAYS are pruned, which no code does, so a self-hoster reading that file alone still expected the worker to keep the bucket tidy. It now carries the same sentence as the production example. And that sentence overreached in the other direction: "the worker never deletes an object" is not true of `_healthcheck/.bin`, the 1-byte key the Test-connection button writes and then deletes. The delete swallows its own failure, so an operator who granted exactly the three documented actions leaves one orphan per press and is never told. Both files now say "a BACKUP object", and name the one key and what happens without DeleteObject on that prefix. --- .env.example | 10 +++++++++- .env.production.example | 9 ++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 3c02cfbec..31cee47cc 100644 --- a/.env.example +++ b/.env.example @@ -207,12 +207,20 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # the backup silently. The backup runs at 02:30 Europe/Berlin via pg-boss cron. # Restore with `pnpm tsx scripts/restore-backup.ts `. # See docs/ops/backup-restore.md for the format spec + step-by-step. +# +# The worker never deletes a BACKUP object -- its grant is PutObject, GetObject +# and AbortMultipartUpload -- so BACKUP_RETENTION_DAYS is the number you set +# the bucket's own lifecycle rule to, not something the worker enforces. The +# one key it does delete is its own connection test's `_healthcheck/.bin`, +# and that delete is best-effort: without DeleteObject on that prefix every +# Test-connection press leaves one 1-byte orphan behind, silently. +# # BACKUP_S3_ENDPOINT="https://.r2.cloudflarestorage.com" # BACKUP_S3_BUCKET="healthlog-backups" # BACKUP_S3_ACCESS_KEY="" # BACKUP_S3_SECRET_KEY="" # BACKUP_S3_REGION="auto" # "auto" for Cloudflare R2; real region for AWS S3 -# BACKUP_RETENTION_DAYS="30" # objects older than this are pruned (best-effort; prefer a bucket lifecycle rule) +# BACKUP_RETENTION_DAYS="30" # the number for your bucket lifecycle rule (see above) # BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY diff --git a/.env.production.example b/.env.production.example index 4a56a53b6..27486de2b 100644 --- a/.env.production.example +++ b/.env.production.example @@ -315,9 +315,12 @@ API_TOKEN_HMAC_KEY="" # BACKUP_S3_REGION="auto" # # How long backup objects are meant to live, in days. Default 30, minimum 1. -# The worker never deletes an object -- its grant is PutObject, GetObject and -# AbortMultipartUpload -- so this number is what you set the bucket's own -# lifecycle rule to. See docs/ops/backup-restore.md. +# The worker never deletes a BACKUP object -- its grant is PutObject, GetObject +# and AbortMultipartUpload -- so this number is what you set the bucket's own +# lifecycle rule to. The one key it does delete is its own connection test's +# `_healthcheck/.bin`, and that delete is best-effort: without DeleteObject +# on that prefix every Test-connection press leaves one 1-byte orphan behind, +# silently. See docs/ops/backup-restore.md. # BACKUP_RETENTION_DAYS="30" From 7f54f339b27a38255ca49a4695a41c49b2dafdad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:38:40 +0200 Subject: [PATCH 48/50] refactor(backup): drop the retention value nothing could ever read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2. `loadOffhostConfig` parsed BACKUP_RETENTION_DAYS, clamped it to a minimum of one day and put it on the config object, where no caller read it. Neither annotation fits: it is not deliberately internal, because there is no internal use, and no consumer is pending. The design forbids one outright — the worker's grant excludes DeleteObject on purpose, so nothing here can act on a retention window without giving a compromised worker the ability to wipe the history. A clamp that guards nothing only read as an enforcer the worker is not. So the field and the clamp go, and the reason sits where the parse was. The variable keeps its place in the two example files and on the compose whitelist: it is the number the operator sets the bucket's own lifecycle rule to. The docs table said "defaults to 30", which implied the app had an opinion; it now says the app does not read it. --- docs/ops/backup-restore.md | 7 ++++--- src/lib/jobs/__tests__/offhost-backup.test.ts | 5 ++++- src/lib/jobs/offhost-backup.ts | 19 +++++++++---------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 0ac2d7ad5..64e7854e9 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -41,7 +41,7 @@ the restore script needs no flag to tell them apart. | `BACKUP_S3_ACCESS_KEY` | yes | | | `BACKUP_S3_SECRET_KEY` | yes | | | `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) | -| `BACKUP_RETENTION_DAYS` | no | defaults to `30` | +| `BACKUP_RETENTION_DAYS` | no | not read by the app; the number for your bucket's lifecycle rule | ## Bucket permissions @@ -56,8 +56,9 @@ Cloudflare R2 the **Object Read & Write** token already covers all three. The worker never deletes a backup object: its grant covers PutObject, GetObject and AbortMultipartUpload only, so a compromised worker cannot -wipe the history. Expiry is the storage provider's lifecycle rule, set to -match `BACKUP_RETENTION_DAYS`: +wipe the history, and nothing in the app reads `BACKUP_RETENTION_DAYS`. +Expiry is the storage provider's lifecycle rule, set to match the number +you wrote there: ``` Filter: "" (all objects) diff --git a/src/lib/jobs/__tests__/offhost-backup.test.ts b/src/lib/jobs/__tests__/offhost-backup.test.ts index f507cb1f5..5a630b088 100644 --- a/src/lib/jobs/__tests__/offhost-backup.test.ts +++ b/src/lib/jobs/__tests__/offhost-backup.test.ts @@ -89,6 +89,10 @@ describe("loadOffhostConfig", () => { expect(loadOffhostConfig()).toBeNull(); }); + // No `retentionDays` here on purpose: the worker cannot enforce retention + // (DeleteObject is kept out of its grant), so the config carries no field + // for it and `BACKUP_RETENTION_DAYS` stays the operator's number for the + // bucket's own lifecycle rule. it("parses a complete config", () => { vi.stubEnv("BACKUP_S3_ENDPOINT", "https://r2.example"); vi.stubEnv("BACKUP_S3_BUCKET", "hl-backups"); @@ -102,7 +106,6 @@ describe("loadOffhostConfig", () => { expect(cfg!.endpoint).toBe("https://r2.example"); expect(cfg!.region).toBe("auto"); expect(cfg!.encryptionKey.length).toBe(32); - expect(cfg!.retentionDays).toBe(30); }); }); diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index 9f868623e..0f55273c0 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -13,7 +13,8 @@ * * Retention: the worker NEVER calls DeleteObject on backup keys. Operators * MUST configure a bucket-level lifecycle rule (e.g. expire after - * `BACKUP_RETENTION_DAYS`). This keeps the IAM grant for the worker + * `BACKUP_RETENTION_DAYS`, which this module reads nowhere — see + * `loadOffhostConfig`). This keeps the IAM grant for the worker * limited to PutObject + GetObject + AbortMultipartUpload, so a compromised * worker cannot wipe the backup history. The abort is what cleans up a run * that failed partway rather than leaving billed, unlistable parts behind; @@ -40,7 +41,6 @@ export interface OffhostBackupConfig { secretKey: string; region: string; encryptionKey: Buffer; - retentionDays: number; } export class OffhostBackupNotConfiguredError extends Error { @@ -69,13 +69,13 @@ export function loadOffhostConfig(): OffhostBackupConfig | null { const encRaw = process.env.BACKUP_ENCRYPTION_KEY; if (!endpoint || !bucket || !accessKey || !secretKey || !encRaw) return null; - const retentionDays = (() => { - const raw = process.env.BACKUP_RETENTION_DAYS; - if (!raw) return 30; - const v = parseInt(raw, 10); - return Number.isFinite(v) && v >= 1 ? v : 30; - })(); - + // `BACKUP_RETENTION_DAYS` is deliberately absent from this config. It used + // to be parsed and clamped here and then read by nobody, which read as an + // enforcer the worker is not: retention belongs to the bucket's lifecycle + // rule, and no consumer can ever appear here because DeleteObject is kept + // out of the worker's grant on purpose (see the header). The variable stays + // documented and on the compose whitelist because it is the number the + // operator sets that rule to. return { endpoint, bucket, @@ -83,7 +83,6 @@ export function loadOffhostConfig(): OffhostBackupConfig | null { secretKey, region: process.env.BACKUP_S3_REGION ?? "auto", encryptionKey: decodeBackupKey(encRaw), - retentionDays, }; } From 3d163d3602b045f90033963d07d9bae50a04584b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 03:05:20 +0200 Subject: [PATCH 49/50] =?UTF-8?q?chore(release):=20v1.38.14=20=E2=80=94=20?= =?UTF-8?q?the=20reported=20bugs=20at=20their=20class,=20and=20a=20gate=20?= =?UTF-8?q?that=20stops=20crying=20wolf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version anchors, the OpenAPI document and the changelog entry. Refs #943, #944. --- CHANGELOG.md | 92 +++++++++++++++++++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1be66997d..8b676a879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,97 @@ # Changelog +## [1.38.14] — 2026-09-09 + +Two reported bugs fixed at their class, and the release gate stops crying +wolf. Both reports came from @mbreitkreuz, each traced to the line (#943, +#944). + +### Fixed + +- **Walking distance from an `export.zip` import was stored a thousand times + too small (#944).** Apple stamps every record in the archive with the + account's own display unit, and the import read the number without ever + reading that unit, so a 2.5 km day landed as 2.5 metres. HealthKit units + now convert through one shared table (`src/lib/measurements/hk-units.ts`), + the same conversion the workout import already used, which also caught a + workout archived with its energy in kilojoules. Every mapping entry that + keeps a fixed unit carries a written reason, enforced by a structural test. + Re-importing the archive repairs stored rows; for accounts without their + archive, `scripts/repair-apple-health-distance.ts` repairs exactly the rows + the export path wrote, dry-run by default, once per account. Runbook under + `docs/ops/apple-health-distance-repair.md`. + +- **Blood glucose readings without a meal-time tag now count everywhere + (#943).** A meter synced through Apple Health writes no meal-time metadata, + so an account could hold hundreds of readings and see no Blood Glucose + tile, no glucose target and an empty glucose panel in the doctor report, + with the module on and no warning anywhere. Untagged readings are a bucket + of their own (`UNSPECIFIED`): they light the tile, carry its value and + trend, get a target card judged against the random-reading band, and reach + the report and the FHIR export. Tagged accounts keep their per-context + breakdown. A structural test refuses a dashboard tile gated on an enum + without an arm for the untagged case. + +- **Four end-to-end specs that failed through two retries had real causes.** + `a11y` recognised "the chart" by a library class every chart shares (now a + `data-slot`); `documents` maximised the drawer before the vault had booked + the Coach hand-off; `vaccinations` and the record-session fence spec shared + a socket-reuse race in Playwright's keep-alive agent against the server's + five-second idle timeout (seeds now go through the page's own `fetch`). + +- **Three unit specs had a teardown race that was a defect.** The score + tests' fake database was bypassed by a tail call to the real client, which + on a runner without Postgres failed after the test had ended and closed the + worker mid-write. The registration is wrapped, the settle guard covers it, + and the specs run in 20 ms instead of 700. + +- **The first-run profile step no longer throws away what the server told + it.** A height outside the accepted range, or a date the server would not + take, was dropped on save while the wizard moved on and marked setup + finished; the value was gone and nothing said so. A refused field now says + why underneath the input that holds it, in the interface language, and the + step waits. The same applies to the profile card in Settings. A structural + test freezes every submitter of the profile route to that behaviour. The + iOS app has the same gap and is tracked there. + +- **A retried medication write no longer lands twice.** Adding a medication, + logging a side effect and registering a supply container now honour + `Idempotency-Key`: a replay after a lost response returns the first answer + with `X-Idempotent-Replay: true` instead of creating a second row, which is + what the native client's offline queue relies on. The API contract says so. + +### Changed + +- **Release gate hygiene.** Bundle budgets carry a measured baseline per route + and 20 KB of headroom instead of one; the rate-limit integration test no + longer expects four database round trips inside 50 ms; the geometry checks + fail rather than skip when no browser is present, and the dependency-bump + workflow installs one; the dependency audit runs on pushes to `main` and + daily instead of failing pull requests over advisories that changed + nothing. + +- **A synthetic journey after a deploy.** `scripts/synthetic-journey.mjs` + warms an instance, checks `/api/version` against the tag just published, + signs in, writes a reading with an idempotency key, reads it back and + deletes it, one line per leg; `.github/workflows/synthetic-journey.yml` + runs it by hand against a chosen instance. `docs/ops/deploy.md` says how and + what a red leg means. + +- **Two more journeys in the gate.** Blood glucose in both units through the + capture form, the unit switch and the dashboard tiles; an Apple Health + `export.zip` through the import card to the measurements list with units + asserted. Both carry a skipped assertion that turns on with this release's + fixes, and both were broken deliberately once to prove they can fail. + +- **`POST /api/nightscout/connect` is documented.** It sat in the route + coverage guard as an OAuth hand-off, which it is not; the native client's + reconciliation now finds it. + +- **Off-host backup variables documented.** `BACKUP_S3_REGION` and + `BACKUP_RETENTION_DAYS` are in `.env.production.example`, and the runbook no + longer claims the worker prunes old objects: it never deletes, the bucket + lifecycle rule does. + ## [1.38.13] — 2026-09-09 A guardian acting on a managed profile gets the add, edit and delete diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e963177b5..d5b69b924 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.38.13 + version: 1.38.14 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 8321be82a..e5f06bd40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.38.13", + "version": "1.38.14", "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 bfb9d7a72..2233e7219 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.38.13"; + /* @sw-version-fallback */ "v1.38.14"; 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/*` From fd337a4e7ac73ad1df7c0938ef234318252e5d9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 03:25:16 +0200 Subject: [PATCH 50/50] fix(import): match the molar-mass annotation by the shape Apple writes The catch-all angle-bracket pattern read as an HTML sanitiser to static analysis. The annotation Apple writes is a number, so the pattern now matches exactly that, and a unit with anything else between the brackets stays unknown instead of being silently trimmed into a lookup. --- src/lib/measurements/__tests__/hk-units.test.ts | 2 ++ src/lib/measurements/hk-units.ts | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lib/measurements/__tests__/hk-units.test.ts b/src/lib/measurements/__tests__/hk-units.test.ts index b1162fb6b..d03b77939 100644 --- a/src/lib/measurements/__tests__/hk-units.test.ts +++ b/src/lib/measurements/__tests__/hk-units.test.ts @@ -30,6 +30,8 @@ describe("convertHkValue", () => { 99.0858, 4, ); + expect(isConvertibleHkUnit("mmol<180>/L")).toBe(true); + expect(isConvertibleHkUnit("mmol/L")).toBe(false); }); it("keeps the small calorie apart from the large one", () => { diff --git a/src/lib/measurements/hk-units.ts b/src/lib/measurements/hk-units.ts index f500be22d..1b5201b4c 100644 --- a/src/lib/measurements/hk-units.ts +++ b/src/lib/measurements/hk-units.ts @@ -120,10 +120,12 @@ const HK_UNITS: Record = { /** * Apple writes molar units with the substance's molar mass inline — - * `mmol<180.156>/L` for blood glucose. The annotation is informational; - * strip it so the lookup sees the plain unit. + * `mmol<180.156>/L` for blood glucose. The annotation is a number and + * informational; strip exactly that shape so the lookup sees the plain + * unit. Anything else between angle brackets is not a unit Apple writes + * and stays unknown. */ -const MOLAR_MASS_ANNOTATION = /<[^>]*>/g; +const MOLAR_MASS_ANNOTATION = /<\d+(?:\.\d+)?>/g; function normaliseUnitString(unit: string): string { return unit.trim().replace(MOLAR_MASS_ANNOTATION, "");