fix(integrations): round imported provider values through one normaliser

Fitbit, WHOOP, Oura, Polar and Withings each kept a private
`parseFloat(n.toFixed(2))`, which mis-rounds halves on the double below
the decimal. `importValue` keeps the same two places, rounds half away
from zero on the printed decimal and returns the nearest double, so a
value prints cleanly. A guard keeps `toFixed` and private `round2`
helpers out of the provider directories; the Google Health mapper is the
one listed exception until its next change.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 03:35:41 +02:00
1 parent e452dd62e2
commit 2b61762962
8 files changed
+188 -60

No files matched your search

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { importValue } from "../import-value";
describe("importValue", () => {
it("drops the binary noise provider arithmetic leaves behind", () => {
expect(importValue(72.30000000000001)).toBe(72.3);
expect(importValue(0.1 + 0.2)).toBe(0.3);
// Withings: value * 10^unit.
expect(importValue(78456 * Math.pow(10, -3))).toBe(78.46);
expect(String(importValue(1234 * 0.01))).toBe("12.34");
});
it("rounds halves on the decimal the value prints as, away from zero", () => {
// `(1.005).toFixed(2)` is "1.00".
expect(importValue(1.005)).toBe(1.01);
expect(importValue(-0.125)).toBe(-0.13);
expect(importValue(2.675)).toBe(2.68);
});
it("keeps whole numbers, zero and non-finite values as they are", () => {
expect(importValue(64)).toBe(64);
expect(Object.is(importValue(-0.001), 0)).toBe(true);
expect(importValue(0)).toBe(0);
expect(importValue(Number.NaN)).toBeNaN();
expect(importValue(1e-7)).toBe(0);
expect(importValue(1e22)).toBe(1e22);
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* The one rounding a provider value gets on its way in.
*
* Provider payloads arrive as binary floats that carry noise from the
* provider's own arithmetic and from ours (Withings sends `value * 10^unit`,
* seconds become minutes, metres become kilometres): `72.30000000000001`
* stored as is shows up in a chart tooltip, an export and a delta. Every
* provider client used to keep its own `parseFloat(n.toFixed(2))`, which
* also mis-rounds on halves (`(1.005).toFixed(2)` is `"1.00"`, because the
* double below 1.005 is what gets rounded).
*
* Two decimals, rounded half away from zero on the decimal the value prints
* as, so the result is the double nearest to a two-place decimal and prints
* cleanly. Two places is the precision every provider client already stored;
* this keeps it. `src/__tests__/provider-rounding-guard.test.ts` keeps
* `toFixed` out of the provider directories.
*/
export function importValue(n: number): number {
if (!Number.isFinite(n)) return n;
const magnitude = Math.abs(n);
const text = String(magnitude);
// A value printed in exponent form is below a millionth or beyond 1e21;
// shifting its text would not parse, and plain arithmetic is exact enough.
const hundredths = text.includes("e")
? Math.round(magnitude * 100)
: Math.round(Number(`${text}e2`));
return (Math.sign(n) * hundredths) / 100 || 0;
}