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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 06:27:58 +02:00
1 parent f0449574f8
commit 5eaa813b34
3 files changed
+51 -33

No files matched your search

+5 -23
View File
@@ -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<Record<string, string>> = {
"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);
});
});
@@ -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({
+9 -10
View File
@@ -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:<type-tag>:<YYYY-MM-DD>` — 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,