From 2b617629629d59263ff9ab47f8bb5203011beea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 03:35:41 +0200 Subject: [PATCH] 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. --- src/__tests__/provider-rounding-guard.test.ts | 80 +++++++++++++++++++ src/lib/fitbit/client.ts | 19 ++--- .../__tests__/import-value.test.ts | 29 +++++++ src/lib/integrations/import-value.ts | 28 +++++++ src/lib/oura/client.ts | 31 ++++--- src/lib/polar/client.ts | 27 +++---- src/lib/whoop/client.ts | 31 ++++--- src/lib/withings/client.ts | 3 +- 8 files changed, 188 insertions(+), 60 deletions(-) create mode 100644 src/__tests__/provider-rounding-guard.test.ts create mode 100644 src/lib/integrations/__tests__/import-value.test.ts create mode 100644 src/lib/integrations/import-value.ts diff --git a/src/__tests__/provider-rounding-guard.test.ts b/src/__tests__/provider-rounding-guard.test.ts new file mode 100644 index 000000000..79ff4ba24 --- /dev/null +++ b/src/__tests__/provider-rounding-guard.test.ts @@ -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> = { + "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); + }); +}); diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index b7be1b503..cd8cb3b85 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -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) diff --git a/src/lib/integrations/__tests__/import-value.test.ts b/src/lib/integrations/__tests__/import-value.test.ts new file mode 100644 index 000000000..ff83ad1bf --- /dev/null +++ b/src/lib/integrations/__tests__/import-value.test.ts @@ -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); + }); +}); diff --git a/src/lib/integrations/import-value.ts b/src/lib/integrations/import-value.ts new file mode 100644 index 000000000..a3de3d846 --- /dev/null +++ b/src/lib/integrations/import-value.ts @@ -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; +} diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 876b8d40b..4b9c92078 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -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", diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index a0aabfcc3..932f83335 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -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 { 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 = ::` 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", diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index 8f29c1e7d..533a2866c 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -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, }; } diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index cdd763601..c02117675 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -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, }); }