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,80 @@
/**
* Structural guard: a provider client rounds an imported value through
* `importValue` (`src/lib/integrations/import-value.ts`) and nothing else.
*
* Each client used to keep its own `parseFloat(n.toFixed(2))`. Five copies
* of one rule drift apart, and `toFixed` mis-rounds halves on the double
* below the decimal. A `toFixed` or a private `round2` in a provider
* directory is the class coming back.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const LIB = join(process.cwd(), "src", "lib");
const PROVIDER_DIRS = [
"apple-health",
"dawarich",
"fitbit",
"google-health",
"nightscout",
"oura",
"polar",
"strava",
"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/;
function strip(text: string): string {
return text
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
describe("provider clients round through importValue", () => {
const files = walkSourceFiles(LIB, { floor: 1000 }).filter(
(f) =>
PROVIDER_DIRS.some((d) => f.startsWith(`${d}/`)) &&
!f.includes("__tests__") &&
!f.endsWith(".test.ts"),
);
it("walks the provider directories", () => {
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"))),
);
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("value: importValue(n)")).toBe(false);
});
});
+8 -11
View File
@@ -43,6 +43,7 @@ import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import { FitbitApiError, classifyFitbitResponse } from "./response-classifier";
import { envValue } from "@/lib/env";
import { importValue } from "@/lib/integrations/import-value";
/** Classic Fitbit Web API base. */
export const FITBIT_API_BASE = "https://api.fitbit.com";
@@ -505,10 +506,6 @@ async function fitbitGet(
// `FitbitMappedMeasurement[]` (the source + final externalId are stamped by the
// sync layer).
function round2(n: number): number {
return parseFloat(n.toFixed(2));
}
/** Finite + strictly-positive guard. */
function positive(n: unknown): n is number {
return typeof n === "number" && Number.isFinite(n) && n > 0;
@@ -637,7 +634,7 @@ export function mapWeight(
if (!positive(e.weight)) continue;
out.push({
type: "WEIGHT",
value: round2(e.weight),
value: importValue(e.weight),
unit: "kg",
measuredAt: bodyLogInstant(e, tz),
fieldTag: `${bodyLogAnchor(e)}:weight`,
@@ -657,7 +654,7 @@ export function mapBodyFat(
if (!positive(e.fat)) continue;
out.push({
type: "BODY_FAT",
value: round2(e.fat),
value: importValue(e.fat),
unit: "%",
measuredAt: bodyLogInstant(e, tz),
fieldTag: `${bodyLogAnchor(e)}:body_fat`,
@@ -703,7 +700,7 @@ function mapDailySummary(
const scaled = spec.factor ? value * spec.factor : value;
out.push({
type: spec.type,
value: round2(scaled),
value: importValue(scaled),
unit: spec.unit,
measuredAt: canonicalDailyTimestamp(day, tz),
fieldTag: `${day}:${spec.fieldTag}`,
@@ -822,7 +819,7 @@ export function mapVo2Max(
if (v === null || !positive(v)) continue;
out.push({
type: "VO2_MAX",
value: round2(v),
value: importValue(v),
unit: "mL/(kg·min)",
measuredAt: canonicalDailyTimestamp(day, tz),
fieldTag: `vo2_max:${day}`,
@@ -880,7 +877,7 @@ function mapActivitySeries(
const scaled = spec.factor ? n * spec.factor : n;
out.push({
type: spec.type,
value: round2(scaled),
value: importValue(scaled),
unit: spec.unit,
measuredAt: canonicalDailyTimestamp(day, tz),
fieldTag: `${spec.fieldTag}:${day}`,
@@ -1221,7 +1218,7 @@ export function mapSleepSessionDetailed(
if (!windowEnd || end > windowEnd) windowEnd = end;
rows.push({
type: "SLEEP_DURATION",
value: round2(minutes),
value: importValue(minutes),
unit: "minutes",
measuredAt: end,
fieldTag: `${anchor}:sleep:${start.toISOString()}`,
@@ -1359,7 +1356,7 @@ export function mapWorkout(
// distance is km under the metric locale → metres.
const distanceM =
typeof entry.distance === "number" && entry.distance >= 0
? round2(entry.distance * 1000)
? importValue(entry.distance * 1000)
: null;
const avgHr = positive(entry.averageHeartRate)
? Math.round(entry.averageHeartRate)
@@ -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;
}
+14 -17
View File
@@ -21,6 +21,7 @@ import { dayDiff } from "@/lib/cycle/day-math";
import { safeFetch } from "@/lib/safe-fetch";
import { OuraApiError, classifyOuraResponse } from "./response-classifier";
import { envOr, envValue } from "@/lib/env";
import { importValue } from "@/lib/integrations/import-value";
const OURA_API_BASE = "https://api.ouraring.com";
const OURA_OAUTH_AUTH_URL = "https://cloud.ouraring.com/oauth/authorize";
@@ -642,10 +643,6 @@ export function fetchDailyCyclePhases(
const SEC_TO_MIN = 1 / 60;
function round2(n: number): number {
return parseFloat(n.toFixed(2));
}
export interface MappedMeasurement {
type: string;
value: number;
@@ -704,7 +701,7 @@ export function mapReadiness(
if (typeof r.score === "number") {
out.push({
type: "RECOVERY_SCORE",
value: round2(r.score),
value: importValue(r.score),
unit: "score",
measuredAt,
fieldTag: "recovery",
@@ -713,7 +710,7 @@ export function mapReadiness(
if (typeof r.temperature_deviation === "number") {
out.push({
type: "BODY_TEMPERATURE_DEVIATION",
value: round2(r.temperature_deviation),
value: importValue(r.temperature_deviation),
unit: "celsius",
measuredAt,
fieldTag: "temp_deviation",
@@ -784,7 +781,7 @@ function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null {
const fieldTag = `seg:${new Date(segStartMs).toISOString()}`;
out.push({
type: "SLEEP_DURATION",
value: round2(run * HYPNOGRAM_INTERVAL_MIN),
value: importValue(run * HYPNOGRAM_INTERVAL_MIN),
unit: "minutes",
measuredAt: new Date(segEndMs),
fieldTag,
@@ -850,7 +847,7 @@ export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] {
if (typeof sec === "number" && sec >= 0) {
out.push({
type: "SLEEP_DURATION",
value: round2(sec * SEC_TO_MIN),
value: importValue(sec * SEC_TO_MIN),
unit: "minutes",
measuredAt,
fieldTag,
@@ -868,7 +865,7 @@ export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] {
if (typeof s.efficiency === "number") {
out.push({
type: "SLEEP_EFFICIENCY",
value: round2(s.efficiency),
value: importValue(s.efficiency),
unit: "%",
measuredAt,
fieldTag: "sleep_eff",
@@ -878,7 +875,7 @@ export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] {
if (typeof s.average_hrv === "number" && s.average_hrv > 0) {
out.push({
type: "HRV_RMSSD",
value: round2(s.average_hrv),
value: importValue(s.average_hrv),
unit: "ms",
measuredAt,
fieldTag: "hrv_rmssd",
@@ -898,7 +895,7 @@ export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] {
if (typeof s.average_breath === "number" && s.average_breath > 0) {
out.push({
type: "RESPIRATORY_RATE",
value: round2(s.average_breath),
value: importValue(s.average_breath),
unit: "breaths/min",
measuredAt,
fieldTag: "resp_rate",
@@ -919,7 +916,7 @@ export function mapDailySleep(
return [
{
type: "SLEEP_SCORE",
value: round2(d.score),
value: importValue(d.score),
unit: "score",
measuredAt,
fieldTag: "sleep_score",
@@ -941,7 +938,7 @@ export function mapDailySpo2(
return [
{
type: "OXYGEN_SATURATION",
value: round2(avg),
value: importValue(avg),
unit: "%",
measuredAt,
fieldTag: "spo2",
@@ -970,7 +967,7 @@ export function mapDailyActivity(
if (typeof a.active_calories === "number" && a.active_calories >= 0) {
out.push({
type: "ACTIVE_ENERGY_BURNED",
value: round2(a.active_calories),
value: importValue(a.active_calories),
unit: "kcal",
measuredAt,
fieldTag: "active_energy",
@@ -984,7 +981,7 @@ export function mapDailyActivity(
type: "WALKING_RUNNING_DISTANCE",
// Oura reports the equivalent walking distance in metres — the canonical
// WALKING_RUNNING_DISTANCE unit (no conversion).
value: round2(a.equivalent_walking_distance),
value: importValue(a.equivalent_walking_distance),
unit: "m",
measuredAt,
fieldTag: "distance",
@@ -1005,7 +1002,7 @@ export function mapVo2Max(v: OuraVo2Max, tz?: string): MappedMeasurement[] {
return [
{
type: "VO2_MAX",
value: round2(v.vo2_max),
value: importValue(v.vo2_max),
unit: "mL/(kg·min)",
measuredAt,
fieldTag: "vo2_max",
@@ -1029,7 +1026,7 @@ export function mapCardiovascularAge(
return [
{
type: "VASCULAR_AGE",
value: round2(c.vascular_age),
value: importValue(c.vascular_age),
unit: "years",
measuredAt,
fieldTag: "vascular_age",
+12 -15
View File
@@ -32,6 +32,7 @@ import type { WorkoutSportType } from "@/lib/validations/workout";
import { PolarApiError, classifyPolarResponse } from "./response-classifier";
import { mapPolarSportType } from "./sport-map";
import { envOr, envValue } from "@/lib/env";
import { importValue } from "@/lib/integrations/import-value";
const POLAR_API_BASE = "https://www.polaraccesslink.com";
const POLAR_OAUTH_AUTH_URL = "https://flow.polar.com/oauth2/authorization";
@@ -401,10 +402,6 @@ export function fetchSpo2(accessToken: string): Promise<PolarSpo2[]> {
const SEC_TO_MIN = 1 / 60;
function round2(n: number): number {
return parseFloat(n.toFixed(2));
}
/** A single mapped reading destined for one `Measurement` row. The sync layer
* stamps `source = POLAR` + `externalId = <resource>:<date>:<fieldTag>` unless
* the mapper supplies its own full `externalId` (the per-segment sleep rows
@@ -446,7 +443,7 @@ export interface MappedMeasurement {
* rescale 1–6 → 0–100 (1 = worst, 6 = best). */
function rechargeStatusToScore(status: number): number {
const clamped = Math.max(1, Math.min(6, status));
return round2(((clamped - 1) / 5) * 100);
return importValue(((clamped - 1) / 5) * 100);
}
/**
@@ -479,7 +476,7 @@ export function mapNightlyRecharge(
) {
out.push({
type: "HRV_RMSSD",
value: round2(r.heart_rate_variability_avg),
value: importValue(r.heart_rate_variability_avg),
unit: "ms",
measuredAt,
fieldTag: "hrv_rmssd",
@@ -497,7 +494,7 @@ export function mapNightlyRecharge(
if (typeof r.breathing_rate_avg === "number" && r.breathing_rate_avg > 0) {
out.push({
type: "RESPIRATORY_RATE",
value: round2(r.breathing_rate_avg),
value: importValue(r.breathing_rate_avg),
unit: "breaths/min",
measuredAt,
fieldTag: "resp_rate",
@@ -511,7 +508,7 @@ export function mapNightlyRecharge(
if (typeof r.ans_charge === "number") {
out.push({
type: "ANS_CHARGE",
value: round2(r.ans_charge),
value: importValue(r.ans_charge),
unit: "score",
measuredAt,
fieldTag: "ans_charge",
@@ -600,7 +597,7 @@ export function mapSleep(s: PolarSleep, tz?: string): MappedMeasurement[] {
if (typeof s.sleep_score === "number") {
out.push({
type: "SLEEP_PERFORMANCE",
value: round2(s.sleep_score),
value: importValue(s.sleep_score),
unit: "%",
measuredAt: new Date(endMs),
fieldTag: "sleep_score",
@@ -618,7 +615,7 @@ export function mapSleep(s: PolarSleep, tz?: string): MappedMeasurement[] {
if (typeof sec === "number" && sec >= 0) {
out.push({
type: "SLEEP_DURATION",
value: round2(sec * SEC_TO_MIN),
value: importValue(sec * SEC_TO_MIN),
unit: "minutes",
measuredAt,
fieldTag,
@@ -629,7 +626,7 @@ export function mapSleep(s: PolarSleep, tz?: string): MappedMeasurement[] {
if (typeof s.sleep_score === "number") {
out.push({
type: "SLEEP_PERFORMANCE",
value: round2(s.sleep_score),
value: importValue(s.sleep_score),
unit: "%",
measuredAt,
fieldTag: "sleep_score",
@@ -664,7 +661,7 @@ export function mapActivity(
if (typeof activeCalories === "number" && activeCalories >= 0) {
out.push({
type: "ACTIVE_ENERGY_BURNED",
value: round2(activeCalories),
value: importValue(activeCalories),
unit: "kcal",
measuredAt,
fieldTag: "active_energy",
@@ -677,7 +674,7 @@ export function mapActivity(
if (typeof distance === "number" && distance >= 0) {
out.push({
type: "WALKING_RUNNING_DISTANCE",
value: round2(distance),
value: importValue(distance),
unit: "meters",
measuredAt,
fieldTag: "distance",
@@ -699,7 +696,7 @@ export function mapCardioLoad(
return [
{
type: "CARDIO_LOAD",
value: round2(c.cardio_load),
value: importValue(c.cardio_load),
unit: "score",
measuredAt,
fieldTag: "cardio_load",
@@ -719,7 +716,7 @@ export function mapSpo2(r: PolarSpo2): MappedMeasurement[] {
return [
{
type: "OXYGEN_SATURATION",
value: round2(pct),
value: importValue(pct),
unit: "%",
measuredAt,
fieldTag: "spo2",
+15 -16
View File
@@ -20,6 +20,7 @@ import { safeFetch } from "@/lib/safe-fetch";
import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline";
import { WhoopApiError, classifyWhoopResponse } from "./response-classifier";
import { envOr, envValue } from "@/lib/env";
import { importValue } from "@/lib/integrations/import-value";
const WHOOP_API_BASE = "https://api.prod.whoop.com/developer";
const WHOOP_OAUTH_AUTH_URL = "https://api.prod.whoop.com/oauth/oauth2/auth";
@@ -522,10 +523,6 @@ export interface MappedMeasurement {
reconstructed?: boolean;
}
function round2(n: number): number {
return parseFloat(n.toFixed(2));
}
/**
* Map one WHOOP recovery record into Measurement readings. An unscored record
* (`score === null`) yields nothing. `recovery.score.recovery_score` →
@@ -540,21 +537,21 @@ export function mapRecovery(r: WhoopRecovery): MappedMeasurement[] {
const out: MappedMeasurement[] = [
{
type: "RECOVERY_SCORE",
value: round2(r.score.recovery_score),
value: importValue(r.score.recovery_score),
unit: "score",
measuredAt,
fieldTag: "recovery",
},
{
type: "HRV_RMSSD",
value: round2(r.score.hrv_rmssd_milli),
value: importValue(r.score.hrv_rmssd_milli),
unit: "ms",
measuredAt,
fieldTag: "hrv_rmssd",
},
{
type: "RESTING_HEART_RATE",
value: round2(r.score.resting_heart_rate),
value: importValue(r.score.resting_heart_rate),
unit: "bpm",
measuredAt,
fieldTag: "rhr",
@@ -563,7 +560,7 @@ export function mapRecovery(r: WhoopRecovery): MappedMeasurement[] {
if (typeof r.score.spo2_percentage === "number") {
out.push({
type: "OXYGEN_SATURATION",
value: round2(r.score.spo2_percentage),
value: importValue(r.score.spo2_percentage),
unit: "%",
measuredAt,
fieldTag: "spo2",
@@ -572,7 +569,7 @@ export function mapRecovery(r: WhoopRecovery): MappedMeasurement[] {
if (typeof r.score.skin_temp_celsius === "number") {
out.push({
type: "SKIN_TEMPERATURE",
value: round2(r.score.skin_temp_celsius),
value: importValue(r.score.skin_temp_celsius),
unit: "celsius",
measuredAt,
fieldTag: "skin_temp",
@@ -665,7 +662,7 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
need.need_from_recent_nap_milli;
out.push({
type: "SLEEP_NEED",
value: round2(totalNeedMilli * MS_TO_MIN),
value: importValue(totalNeedMilli * MS_TO_MIN),
unit: "minutes",
measuredAt,
fieldTag: "sleep_need",
@@ -684,7 +681,7 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
if (typeof value === "number") {
out.push({
type,
value: round2(value),
value: importValue(value),
unit: "%",
measuredAt,
fieldTag,
@@ -695,7 +692,7 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
if (typeof s.score.respiratory_rate === "number") {
out.push({
type: "RESPIRATORY_RATE",
value: round2(s.score.respiratory_rate),
value: importValue(s.score.respiratory_rate),
unit: "breaths/min",
measuredAt,
fieldTag: "resp_rate",
@@ -727,14 +724,14 @@ export function mapCycle(c: WhoopCycle): MappedMeasurement[] {
const out: MappedMeasurement[] = [
{
type: "DAY_STRAIN",
value: round2(c.score.strain),
value: importValue(c.score.strain),
unit: "score",
measuredAt,
fieldTag: "day_strain",
},
{
type: "ENERGY_EXPENDITURE_KJ",
value: round2(c.score.kilojoule),
value: importValue(c.score.kilojoule),
unit: "kJ",
measuredAt,
fieldTag: "energy_kj",
@@ -801,12 +798,14 @@ export function mapBody(b: WhoopBodyMeasurement): MappedBodyMeasurement {
const positive = (n: number | undefined): n is number =>
typeof n === "number" && Number.isFinite(n) && n > 0;
return {
weightKg: positive(b.weight_kilogram) ? round2(b.weight_kilogram) : null,
weightKg: positive(b.weight_kilogram)
? importValue(b.weight_kilogram)
: null,
maxHeartRate: positive(b.max_heart_rate)
? Math.round(b.max_heart_rate)
: null,
heightCm: positive(b.height_meter)
? round2(b.height_meter * M_TO_CM)
? importValue(b.height_meter * M_TO_CM)
: null,
};
}
+2 -1
View File
@@ -9,6 +9,7 @@ import {
classifyWithingsResponse,
} from "./response-classifier";
import { envOr, envValue } from "@/lib/env";
import { importValue } from "@/lib/integrations/import-value";
const WITHINGS_OAUTH_URL =
"https://account.withings.com/oauth2_user/authorize2";
@@ -326,7 +327,7 @@ export async function fetchMeasurements(
results.push({
type: mapping.type,
value: parseFloat(value.toFixed(2)),
value: importValue(value),
measuredAt,
});
}