From 5eaa813b34ba3de02921a6f42489aa69405872c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 06:27:58 +0200 Subject: [PATCH] fix(google-health): round imported values through importValue The mapper kept its own parseFloat(n.toFixed(2)), which rounds a half at the third decimal down whenever the double sits just below it: a weight of 72 345 g stored as 72.34 kg, a distance of 1 000 005 mm as 1000 m. The shared importValue rounds on the decimal the value prints as. The rounding guard drops its last pending entry and also catches a round2 declared as a const. --- src/__tests__/provider-rounding-guard.test.ts | 28 +++----------- .../google-health/__tests__/mappers.test.ts | 37 +++++++++++++++++++ src/lib/google-health/mappers.ts | 19 +++++----- 3 files changed, 51 insertions(+), 33 deletions(-) diff --git a/src/__tests__/provider-rounding-guard.test.ts b/src/__tests__/provider-rounding-guard.test.ts index 79ff4ba24..10fe0b655 100644 --- a/src/__tests__/provider-rounding-guard.test.ts +++ b/src/__tests__/provider-rounding-guard.test.ts @@ -26,17 +26,7 @@ const PROVIDER_DIRS = [ "whoop", "withings", ]; -/** - * Files that still round on their own, with the reason. Google Health's - * mapper is the one left: it moves to `importValue` with the next change to - * that directory. - */ -const PENDING: Readonly> = { - "google-health/mappers.ts": - "moves to importValue with the next Google Health change", -}; - -const ROUNDING = /\.toFixed\s*\(|\bfunction\s+round2\b/; +const ROUNDING = /\.toFixed\s*\(|\b(?:function|const|let)\s+round2\b/; function strip(text: string): string { return text @@ -56,25 +46,17 @@ describe("provider clients round through importValue", () => { expect(files.length).toBeGreaterThanOrEqual(60); }); - it("finds no rounding of their own outside the pending list", () => { - const offenders = files.filter( - (f) => - !(f in PENDING) && - ROUNDING.test(strip(readFileSync(join(LIB, f), "utf8"))), + it("finds no rounding of their own", () => { + const offenders = files.filter((f) => + ROUNDING.test(strip(readFileSync(join(LIB, f), "utf8"))), ); expect(offenders).toEqual([]); }); - it("keeps the pending list honest", () => { - const stale = Object.keys(PENDING).filter( - (f) => !ROUNDING.test(strip(readFileSync(join(LIB, f), "utf8"))), - ); - expect(stale).toEqual([]); - }); - it("catches a planted copy", () => { expect(ROUNDING.test("const v = parseFloat(n.toFixed(2));")).toBe(true); expect(ROUNDING.test("function round2(n: number) {}")).toBe(true); + expect(ROUNDING.test("const round2 = (n: number) => n;")).toBe(true); expect(ROUNDING.test("value: importValue(n)")).toBe(false); }); }); diff --git a/src/lib/google-health/__tests__/mappers.test.ts b/src/lib/google-health/__tests__/mappers.test.ts index c20559389..16d119f23 100644 --- a/src/lib/google-health/__tests__/mappers.test.ts +++ b/src/lib/google-health/__tests__/mappers.test.ts @@ -73,6 +73,43 @@ describe("mapWeight — spot sample", () => { }); }); +describe("imported values round half away from zero (importValue)", () => { + // Each of these sits on a half at the third decimal whose double lies just + // below it, where `toFixed(2)` rounded down. + it("rounds a gram-precise weight up on the half", () => { + const [r] = mapWeight({ + weight: { + weightGrams: 72345, + sampleTime: { physicalTime: "2026-06-01T08:00:00.000Z" }, + }, + }); + expect(r!.value).toBe(72.35); + }); + + it("rounds a daily distance total up on the half", () => { + const [r] = mapDistance({ + civilStartTime: { date: { year: 2026, month: 6, day: 1 } }, + distance: { millimetersSum: "1000005" }, + }); + expect(r!.value).toBe(1000.01); + }); + + it("rounds a session distance up on the half", () => { + const w = mapWorkout({ + name: "users/me/dataTypes/exercise/dataPoints/half", + exercise: { + exerciseType: "RUNNING", + interval: { + startTime: "2026-06-03T07:00:00.000Z", + endTime: "2026-06-03T07:45:00.000Z", + }, + metricsSummary: { distanceMillimeters: "8000025" }, + }, + }); + expect(w!.totalDistanceM).toBe(8000.03); + }); +}); + describe("mapBodyFat — camelCase union key", () => { it("reads the percentage under the camelCase bodyFat key", () => { const rows = mapBodyFat({ diff --git a/src/lib/google-health/mappers.ts b/src/lib/google-health/mappers.ts index 194a782ff..0e6bfe02a 100644 --- a/src/lib/google-health/mappers.ts +++ b/src/lib/google-health/mappers.ts @@ -14,6 +14,7 @@ * this file performs I/O — everything is synchronous and deterministic given a * payload (+ optional user timezone). */ +import { importValue } from "@/lib/integrations/import-value"; import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; @@ -217,10 +218,6 @@ export interface GoogleHealthRollupPoint { /** Millimetres → centimetres (Google `height.heightMillimeters` → `User.heightCm`). */ const MM_TO_CM = 0.1; -function round2(n: number): number { - return parseFloat(n.toFixed(2)); -} - /** * Coerce a raw payload value to a finite number, or null. proto3 int64 fields * arrive as JSON strings (`"12345"`); `Number()` handles both int and decimal @@ -445,7 +442,7 @@ function mapSimple( return [ { type: spec.type, - value: round2(value), + value: importValue(value), unit: spec.unit, measuredAt: resolveMeasuredAt(point, dataType, new Date(), tz), fieldTag: `${externalAnchor(point, dataType)}:${spec.fieldTag}`, @@ -678,7 +675,7 @@ export function mapHeight( if (mm === null) return null; const t = readPath(point, `${dt.key}.sampleTime.physicalTime`); const sampledAt = typeof t === "string" ? parseLocalInstant(t) : null; - return { cm: round2(mm * MM_TO_CM), sampledAt }; + return { cm: importValue(mm * MM_TO_CM), sampledAt }; } // ─── Activity mappers: daily roll-up totals ──────────────────── @@ -732,7 +729,7 @@ function mapDailyRollup( return [ { type: spec.type, - value: round2(value), + value: importValue(value), unit: spec.unit, measuredAt: canonicalDailyTimestamp(dayKey, tz), // `stats::` — the sync layer reads `cumulativeDaily` @@ -833,7 +830,7 @@ export function mapVo2Max( return [ { type: "VO2_MAX", - value: round2(value), + value: importValue(value), unit: "mL/(kg·min)", measuredAt, fieldTag: `vo2_max:${externalAnchor(point, dt)}`, @@ -1035,7 +1032,7 @@ export function mapSleepSessionDetailed( if (!windowEnd || end > windowEnd) windowEnd = end; rows.push({ type: "SLEEP_DURATION", - value: round2(mins), + value: importValue(mins), unit: "minutes", measuredAt: end, fieldTag: `${anchor}:sleep:${start.toISOString()}`, @@ -1287,7 +1284,9 @@ export function mapWorkout( durationSec, totalEnergyKcal: energyKcal !== null ? Math.round(energyKcal) : null, totalDistanceM: - distanceMm !== null && distanceMm >= 0 ? round2(distanceMm / 1000) : null, + distanceMm !== null && distanceMm >= 0 + ? importValue(distanceMm / 1000) + : null, avgHeartRate: avgHr !== null && avgHr > 0 ? Math.round(avgHr) : null, // metricsSummary carries no maximum/minimum heart rate. maxHeartRate: null,