mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user