mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
f0449574f8
commit
5eaa813b34
3 files changed
+51
-33
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user