mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(google-health): parse the camelCase v4 payloads and correct the read methods
The v4 client read none of its documented response shapes: the DataPoint value is a camelCase union keyed by the camelCase type name with camelCase nested times, proto3 int64 fields arrive as JSON strings, and four fetchers targeted wrong data types or illegal filter fields. Every HTTP-200 type parsed zero measurements and four fetchers returned HTTP 400. - Read every value/time path in camelCase; keep snake_case only inside the filter parameter. Pin path/filter/payload-key triples per data type. - Coerce int64 JSON strings in every numeric extractor. - weight.weightGrams -> kg, height.heightMeters -> cm, distance millimetres -> m, bodyFat.percentage, heartRate.beatsPerMinute. - Move SpO2, HRV, respiratory rate and VO2 max to their daily-* data types (the bare sample types reject a .date filter). - Move steps, distance, active energy and floors to POST :dailyRollUp with windowSizeDays 1 (list returns minute-grain buckets, not daily totals; floors has no list method), chunked at 90 civil days, day-keyed on civilStartTime.date at the UTC-midday anchor. - Filter sleep on interval.end_time (the only legal sleep time field) and anchor sessions on interval.endTime; filter exercise on interval.civil_start_time with an offset-less civil bound in the user's zone; read exercise metrics from metricsSummary and take the workout id from the DataPoint name. - Seed height from max(sampleTime) explicitly - list order is descending, so last-row-wins picked the oldest sample. - Rewrite the mapper fixtures to the documented shapes.
This commit is contained in:
1 parent
37449c74b0
commit
295a6a8050
5 files changed
+998
-565
No files matched your search
@@ -1,33 +1,49 @@
|
||||
/**
|
||||
* Representative-payload tests for the Google Health data-point mappers.
|
||||
* Documented-payload tests for the Google Health data-point mappers.
|
||||
*
|
||||
* The Google value-field JSON is undocumented, so each mapper hands a small set
|
||||
* of candidate shapes; these tests pin the launch-metric happy paths — the
|
||||
* MeasurementType, unit, value (incl. km→m conversion), the resolved
|
||||
* `measuredAt` timestamp/timezone, and the externalId `fieldTag` grain (spot
|
||||
* anchor vs the `stats:`-style daily-total overwrite key vs per-sleep-stage).
|
||||
* Fixtures follow the official v4 response shapes: the DataPoint value is a
|
||||
* camelCase union keyed by the camelCase type name (`bodyFat`,
|
||||
* `dailyRestingHeartRate`, …) with camelCase nested times
|
||||
* (`sampleTime.physicalTime`, `interval.startTime`), proto3 int64 fields arrive
|
||||
* as JSON strings (`"8500"`), daily-summary `date` fields are `{year,month,day}`
|
||||
* objects, and the cumulative activity totals come from `:dailyRollUp`
|
||||
* aggregate windows (`civilStartTime.date` + `*Sum` fields). The tests pin the
|
||||
* MeasurementType, unit, value (incl. gram/millimetre conversions), the
|
||||
* resolved `measuredAt`, and the externalId `fieldTag` grain (spot anchor vs
|
||||
* the `stats:`-style daily-total overwrite key vs per-sleep-stage).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
chunkCivilRange,
|
||||
formatCivilBound,
|
||||
incrementalFilter,
|
||||
GOOGLE_HEALTH_DATA_TYPES,
|
||||
mapActiveEnergy,
|
||||
mapBodyFat,
|
||||
mapDistance,
|
||||
mapFloors,
|
||||
mapGoogleHealthSleepStage,
|
||||
mapGoogleHealthSportType,
|
||||
mapHeartRate,
|
||||
mapHeartRateVariability,
|
||||
mapHeight,
|
||||
mapOxygenSaturation,
|
||||
mapRespiratoryRate,
|
||||
mapRestingHeartRate,
|
||||
mapSleepSession,
|
||||
mapSteps,
|
||||
mapVo2Max,
|
||||
mapWeight,
|
||||
mapWorkout,
|
||||
} from "../client";
|
||||
|
||||
describe("mapWeight — spot sample", () => {
|
||||
it("maps a kilograms sample to a WEIGHT reading anchored on its physical time", () => {
|
||||
it("maps a weightGrams sample to a WEIGHT reading in kg anchored on its physical time", () => {
|
||||
const rows = mapWeight({
|
||||
weight: {
|
||||
kilograms: 72.53,
|
||||
sample_time: { physical_time: "2026-06-01T08:00:00.000Z" },
|
||||
weightGrams: 72530,
|
||||
sampleTime: { physicalTime: "2026-06-01T08:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
@@ -42,30 +58,72 @@ describe("mapWeight — spot sample", () => {
|
||||
});
|
||||
|
||||
it("returns nothing when no value parses", () => {
|
||||
expect(mapWeight({ weight: { sample_time: {} } })).toEqual([]);
|
||||
expect(mapWeight({ weight: { sampleTime: {} } })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapOxygenSaturation — daily civil-date summary", () => {
|
||||
it("maps a nightly average to a percentage anchored on the civil day", () => {
|
||||
describe("mapBodyFat — camelCase union key", () => {
|
||||
it("reads the percentage under the camelCase bodyFat key", () => {
|
||||
const rows = mapBodyFat({
|
||||
bodyFat: {
|
||||
percentage: 21.7,
|
||||
sampleTime: { physicalTime: "2026-06-01T08:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
type: "BODY_FAT",
|
||||
unit: "%",
|
||||
value: 21.7,
|
||||
fieldTag: "2026-06-01T08:00:00.000Z:body_fat",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapOxygenSaturation — daily-oxygen-saturation summary", () => {
|
||||
it("maps averagePercentage anchored on the {year,month,day} civil date", () => {
|
||||
const rows = mapOxygenSaturation({
|
||||
oxygen_saturation: { average_percentage: 97.4, date: "2026-06-01" },
|
||||
dailyOxygenSaturation: {
|
||||
averagePercentage: 97.4,
|
||||
date: { year: 2026, month: 6, day: 1 },
|
||||
},
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
const r = rows[0]!;
|
||||
expect(r.type).toBe("OXYGEN_SATURATION");
|
||||
expect(r.unit).toBe("%");
|
||||
expect(r.value).toBe(97.4);
|
||||
// A civil-date summary anchors at UTC midnight and keys per-day.
|
||||
expect(r.measuredAt.toISOString()).toBe("2026-06-01T00:00:00.000Z");
|
||||
// Civil-date summaries anchor at UTC midday (tz-shift-proof) and key per-day.
|
||||
expect(r.measuredAt.toISOString()).toBe("2026-06-01T12:00:00.000Z");
|
||||
expect(r.fieldTag).toBe("2026-06-01:spo2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRestingHeartRate", () => {
|
||||
it("maps beats_per_minute to a RESTING_HEART_RATE reading in bpm", () => {
|
||||
describe("mapHeartRateVariability — daily-heart-rate-variability summary", () => {
|
||||
it("maps averageHeartRateVariabilityMilliseconds to the HRV slot in ms", () => {
|
||||
const rows = mapHeartRateVariability({
|
||||
dailyHeartRateVariability: {
|
||||
averageHeartRateVariabilityMilliseconds: 42.5,
|
||||
date: { year: 2026, month: 6, day: 1 },
|
||||
},
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
unit: "ms",
|
||||
value: 42.5,
|
||||
fieldTag: "2026-06-01:hrv",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRestingHeartRate — int64-string coercion", () => {
|
||||
it("coerces the beatsPerMinute int64 JSON string", () => {
|
||||
const rows = mapRestingHeartRate({
|
||||
daily_resting_heart_rate: { beats_per_minute: 54, date: "2026-06-01" },
|
||||
dailyRestingHeartRate: {
|
||||
beatsPerMinute: "54",
|
||||
date: { year: 2026, month: 6, day: 1 },
|
||||
},
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
@@ -77,18 +135,68 @@ describe("mapRestingHeartRate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapSteps — daily cumulative total", () => {
|
||||
it("maps an interval daily total to a cumulative ACTIVITY_STEPS row", () => {
|
||||
const rows = mapSteps({
|
||||
steps: {
|
||||
count: 8500,
|
||||
interval: {
|
||||
start_time: "2026-06-01T00:00:00.000Z",
|
||||
end_time: "2026-06-01T23:59:59.000Z",
|
||||
},
|
||||
describe("mapRespiratoryRate — daily-respiratory-rate summary", () => {
|
||||
it("coerces the dailyRespiratoryRateBpm int64 JSON string", () => {
|
||||
const rows = mapRespiratoryRate({
|
||||
dailyRespiratoryRate: {
|
||||
dailyRespiratoryRateBpm: "14",
|
||||
date: { year: 2026, month: 6, day: 1 },
|
||||
},
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
type: "RESPIRATORY_RATE",
|
||||
unit: "breaths/min",
|
||||
value: 14,
|
||||
fieldTag: "2026-06-01:resp_rate",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapHeartRate — intraday spot sample", () => {
|
||||
it("coerces the beatsPerMinute int64 JSON string and anchors on the instant", () => {
|
||||
const rows = mapHeartRate({
|
||||
heartRate: {
|
||||
beatsPerMinute: "62",
|
||||
sampleTime: { physicalTime: "2026-06-01T09:15:00.000Z" },
|
||||
},
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
type: "PULSE",
|
||||
unit: "bpm",
|
||||
value: 62,
|
||||
fieldTag: "2026-06-01T09:15:00.000Z:hr",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapHeight — profile seed", () => {
|
||||
it("converts heightMeters to cm and surfaces the sample instant", () => {
|
||||
const sample = mapHeight({
|
||||
height: {
|
||||
heightMeters: 1.82,
|
||||
sampleTime: { physicalTime: "2026-06-01T08:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
expect(sample).not.toBeNull();
|
||||
expect(sample!.cm).toBe(182);
|
||||
expect(sample!.sampledAt?.toISOString()).toBe("2026-06-01T08:00:00.000Z");
|
||||
});
|
||||
|
||||
it("returns null when no value parses", () => {
|
||||
expect(mapHeight({ height: {} })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapSteps — dailyRollUp aggregate window", () => {
|
||||
it("maps countSum (int64 string) keyed on civilStartTime.date", () => {
|
||||
const rows = mapSteps({
|
||||
civilStartTime: { date: { year: 2026, month: 6, day: 1 } },
|
||||
civilEndTime: { date: { year: 2026, month: 6, day: 2 } },
|
||||
steps: { countSum: "8500" },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
const r = rows[0]!;
|
||||
expect(r.type).toBe("ACTIVITY_STEPS");
|
||||
expect(r.unit).toBe("steps");
|
||||
@@ -97,72 +205,36 @@ describe("mapSteps — daily cumulative total", () => {
|
||||
// per-day fieldTag) so a re-fetched day replaces rather than duplicates.
|
||||
expect(r.cumulativeDaily).toBe(true);
|
||||
expect(r.fieldTag).toBe("steps:2026-06-01");
|
||||
expect(r.measuredAt.toISOString()).toBe("2026-06-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapSteps — civil-day keying for a non-UTC-midnight interval", () => {
|
||||
it("keys the day + measuredAt on the CIVIL day, not the physical start instant", () => {
|
||||
const rows = mapSteps({
|
||||
steps: {
|
||||
count: 12000,
|
||||
interval: {
|
||||
// Physical instant is 15:00Z on 2026-06-01, but the civil day the
|
||||
// total belongs to is 2026-06-02 (a positive-UTC-offset user's local
|
||||
// midnight). The day-key must follow civil_start_time so Google/Apple/
|
||||
// Fitbit `stats:<tag>:<YYYY-MM-DD>` keys agree — start_time alone would
|
||||
// off-by-one the day.
|
||||
start_time: "2026-06-01T15:00:00.000Z",
|
||||
civil_start_time: "2026-06-02",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
const r = rows[0]!;
|
||||
expect(r.type).toBe("ACTIVITY_STEPS");
|
||||
expect(r.value).toBe(12000);
|
||||
expect(r.cumulativeDaily).toBe(true);
|
||||
// Day-key follows the civil day, not the 2026-06-01 physical instant.
|
||||
expect(r.fieldTag).toBe("steps:2026-06-02");
|
||||
// Anchored at UTC-midday so a tz shift can't roll the civil day.
|
||||
expect(r.measuredAt.toISOString()).toBe("2026-06-02T12:00:00.000Z");
|
||||
expect(r.measuredAt.toISOString()).toBe("2026-06-01T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("drops a window with no parseable civil day (it cannot be keyed)", () => {
|
||||
expect(mapSteps({ steps: { countSum: "1200" } })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapDistance — unit conversion", () => {
|
||||
it("converts a kilometers daily total to metres", () => {
|
||||
describe("mapDistance — millimetre conversion", () => {
|
||||
it("converts millimetersSum (int64 string) to metres", () => {
|
||||
const rows = mapDistance({
|
||||
distance: {
|
||||
kilometers: 5.2,
|
||||
interval: { start_time: "2026-06-01T00:00:00.000Z" },
|
||||
},
|
||||
civilStartTime: { date: { year: 2026, month: 6, day: 1 } },
|
||||
distance: { millimetersSum: "5200000" },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
const r = rows[0]!;
|
||||
expect(r.type).toBe("WALKING_RUNNING_DISTANCE");
|
||||
expect(r.unit).toBe("m");
|
||||
expect(r.value).toBe(5200); // 5.2 km → 5200 m
|
||||
expect(r.value).toBe(5200); // 5 200 000 mm → 5200 m
|
||||
expect(r.cumulativeDaily).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers an explicit metres field when present", () => {
|
||||
const rows = mapDistance({
|
||||
distance: {
|
||||
meters: 8000,
|
||||
interval: { start_time: "2026-06-01T00:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
expect(rows[0]!.value).toBe(8000);
|
||||
expect(r.fieldTag).toBe("distance:2026-06-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapActiveEnergy — preserves a legitimate zero", () => {
|
||||
it("keeps a rest-day 0 kcal (a real reading, not a gap)", () => {
|
||||
it("keeps a rest-day 0 kcalSum (a real reading, not a gap)", () => {
|
||||
const rows = mapActiveEnergy({
|
||||
active_energy_burned: {
|
||||
active_kilocalories: 0,
|
||||
interval: { start_time: "2026-06-01T00:00:00.000Z" },
|
||||
},
|
||||
civilStartTime: { date: { year: 2026, month: 6, day: 1 } },
|
||||
activeEnergyBurned: { kcalSum: 0 },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
@@ -170,30 +242,80 @@ describe("mapActiveEnergy — preserves a legitimate zero", () => {
|
||||
unit: "kcal",
|
||||
value: 0,
|
||||
cumulativeDaily: true,
|
||||
fieldTag: "active_energy:2026-06-01",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapFloors — dailyRollUp aggregate window", () => {
|
||||
it("maps countSum (int64 string) to FLIGHTS_CLIMBED", () => {
|
||||
const rows = mapFloors({
|
||||
civilStartTime: { date: { year: 2026, month: 6, day: 1 } },
|
||||
floors: { countSum: "3" },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
type: "FLIGHTS_CLIMBED",
|
||||
unit: "flights",
|
||||
value: 3,
|
||||
cumulativeDaily: true,
|
||||
fieldTag: "floors:2026-06-01",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapVo2Max — daily-vo2-max summary", () => {
|
||||
it("maps vo2Max keyed per civil day (latest-wins overwrite grain)", () => {
|
||||
const rows = mapVo2Max({
|
||||
dailyVo2Max: {
|
||||
vo2Max: 41.2,
|
||||
estimated: true,
|
||||
date: { year: 2026, month: 6, day: 1 },
|
||||
},
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
type: "VO2_MAX",
|
||||
unit: "mL/(kg·min)",
|
||||
value: 41.2,
|
||||
cumulativeDaily: true,
|
||||
fieldTag: "vo2_max:2026-06-01",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a zero (VO2 max is strictly positive)", () => {
|
||||
expect(
|
||||
mapVo2Max({
|
||||
dailyVo2Max: { vo2Max: 0, date: { year: 2026, month: 6, day: 1 } },
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapSleepSession — per-stage segments", () => {
|
||||
it("emits one SLEEP_DURATION row per known stage, skipping unknown labels", () => {
|
||||
const rows = mapSleepSession({
|
||||
sleep: {
|
||||
interval: { end_time: "2026-06-02T06:30:00.000Z" },
|
||||
interval: {
|
||||
startTime: "2026-06-01T22:30:00.000Z",
|
||||
endTime: "2026-06-02T06:30:00.000Z",
|
||||
},
|
||||
type: "STAGES",
|
||||
stages: [
|
||||
{
|
||||
stage: "deep",
|
||||
start_time: "2026-06-02T02:00:00.000Z",
|
||||
end_time: "2026-06-02T02:45:00.000Z",
|
||||
type: "DEEP",
|
||||
startTime: "2026-06-02T02:00:00.000Z",
|
||||
endTime: "2026-06-02T02:45:00.000Z",
|
||||
},
|
||||
{
|
||||
stage: "rem",
|
||||
start_time: "2026-06-02T03:00:00.000Z",
|
||||
end_time: "2026-06-02T03:30:00.000Z",
|
||||
type: "REM",
|
||||
startTime: "2026-06-02T03:00:00.000Z",
|
||||
endTime: "2026-06-02T03:30:00.000Z",
|
||||
},
|
||||
{
|
||||
stage: "totally_unknown_label",
|
||||
start_time: "2026-06-02T04:00:00.000Z",
|
||||
end_time: "2026-06-02T04:10:00.000Z",
|
||||
type: "SLEEP_STAGE_TYPE_UNSPECIFIED",
|
||||
startTime: "2026-06-02T04:00:00.000Z",
|
||||
endTime: "2026-06-02T04:10:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -206,7 +328,7 @@ describe("mapSleepSession — per-stage segments", () => {
|
||||
expect(deep.value).toBe(45);
|
||||
expect(deep.sleepStage).toBe("DEEP");
|
||||
expect(deep.measuredAt.toISOString()).toBe("2026-06-02T02:45:00.000Z");
|
||||
// Per-stage rows key off the session anchor + an indexed stage tag.
|
||||
// Per-stage rows key off the session END anchor + an indexed stage tag.
|
||||
expect(deep.fieldTag).toBe("2026-06-02T06:30:00.000Z:sleep_deep:0");
|
||||
|
||||
const rem = rows[1]!;
|
||||
@@ -215,52 +337,63 @@ describe("mapSleepSession — per-stage segments", () => {
|
||||
expect(rem.fieldTag).toBe("2026-06-02T06:30:00.000Z:sleep_rem:1");
|
||||
});
|
||||
|
||||
it("maps a 'light' stage onto the shared CORE band", () => {
|
||||
expect(mapGoogleHealthSleepStage("light")).toBe("CORE");
|
||||
it("maps the documented SLEEP_STAGE_TYPE enum onto the shared bands", () => {
|
||||
expect(mapGoogleHealthSleepStage("LIGHT")).toBe("CORE"); // shallow-NREM band
|
||||
expect(mapGoogleHealthSleepStage("REM")).toBe("REM");
|
||||
expect(mapGoogleHealthSleepStage("in-bed")).toBe("IN_BED");
|
||||
expect(mapGoogleHealthSleepStage("RESTLESS")).toBe("AWAKE");
|
||||
expect(mapGoogleHealthSleepStage("ASLEEP")).toBe("ASLEEP");
|
||||
expect(
|
||||
mapGoogleHealthSleepStage("SLEEP_STAGE_TYPE_UNSPECIFIED"),
|
||||
).toBeNull();
|
||||
expect(mapGoogleHealthSleepStage("nonsense")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapWorkout — exercise session", () => {
|
||||
it("maps a running session to a Workout with duration, energy, distance and HR", () => {
|
||||
it("maps a RUNNING session with metricsSummary fields and the top-level name id", () => {
|
||||
const w = mapWorkout({
|
||||
name: "users/me/dataTypes/exercise/dataPoints/abc123",
|
||||
exercise: {
|
||||
activity_type: "running",
|
||||
session_id: "abc123",
|
||||
exerciseType: "RUNNING",
|
||||
interval: {
|
||||
start_time: "2026-06-03T07:00:00.000Z",
|
||||
end_time: "2026-06-03T07:45:00.000Z",
|
||||
startTime: "2026-06-03T07:00:00.000Z",
|
||||
endTime: "2026-06-03T07:45:00.000Z",
|
||||
civilStartTime: {
|
||||
date: { year: 2026, month: 6, day: 3 },
|
||||
time: { hours: 9 },
|
||||
},
|
||||
},
|
||||
metricsSummary: {
|
||||
caloriesKcal: 420,
|
||||
distanceMillimeters: 8000000,
|
||||
averageHeartRateBeatsPerMinute: "150",
|
||||
steps: "7600",
|
||||
},
|
||||
active_kilocalories: 420,
|
||||
distance: { meters: 8000 },
|
||||
average_heart_rate: { beats_per_minute: 150 },
|
||||
maximum_heart_rate: { beats_per_minute: 172 },
|
||||
},
|
||||
});
|
||||
expect(w).not.toBeNull();
|
||||
expect(w).toMatchObject({
|
||||
externalId: "abc123",
|
||||
externalId: "users/me/dataTypes/exercise/dataPoints/abc123",
|
||||
sportType: "running",
|
||||
durationSec: 2700, // 45 min
|
||||
totalEnergyKcal: 420,
|
||||
totalDistanceM: 8000,
|
||||
totalDistanceM: 8000, // 8 000 000 mm → 8000 m
|
||||
avgHeartRate: 150,
|
||||
maxHeartRate: 172,
|
||||
// metricsSummary carries no max/min heart rate.
|
||||
maxHeartRate: null,
|
||||
minHeartRate: null,
|
||||
});
|
||||
expect(w!.startedAt.toISOString()).toBe("2026-06-03T07:00:00.000Z");
|
||||
expect(w!.endedAt.toISOString()).toBe("2026-06-03T07:45:00.000Z");
|
||||
});
|
||||
|
||||
it("falls back to a start-anchored externalId when no session id is present", () => {
|
||||
it("falls back to a start-anchored externalId when the resource name is absent", () => {
|
||||
const w = mapWorkout({
|
||||
exercise: {
|
||||
type: "walk",
|
||||
exerciseType: "WALKING",
|
||||
interval: {
|
||||
start_time: "2026-06-03T09:00:00.000Z",
|
||||
end_time: "2026-06-03T09:30:00.000Z",
|
||||
startTime: "2026-06-03T09:00:00.000Z",
|
||||
endTime: "2026-06-03T09:30:00.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -274,17 +407,114 @@ describe("mapWorkout — exercise session", () => {
|
||||
mapWorkout({
|
||||
exercise: {
|
||||
interval: {
|
||||
start_time: "2026-06-03T09:00:00.000Z",
|
||||
end_time: "2026-06-03T09:00:00.000Z", // zero-length
|
||||
startTime: "2026-06-03T09:00:00.000Z",
|
||||
endTime: "2026-06-03T09:00:00.000Z", // zero-length
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves unknown sport types to a generic label", () => {
|
||||
expect(mapGoogleHealthSportType("kitesurfing")).toBe("other");
|
||||
it("resolves the UPPERCASE ExerciseType enum, with a generic fallback", () => {
|
||||
expect(mapGoogleHealthSportType("STRENGTH_TRAINING")).toBe("strength");
|
||||
expect(mapGoogleHealthSportType("HIGH_INTENSITY_INTERVAL_TRAINING")).toBe(
|
||||
"hiit",
|
||||
);
|
||||
expect(mapGoogleHealthSportType("CYCLING")).toBe("cycling");
|
||||
expect(mapGoogleHealthSportType("KITESURFING")).toBe("other");
|
||||
expect(mapGoogleHealthSportType("")).toBe("other");
|
||||
expect(mapGoogleHealthSportType("Cycling")).toBe("cycling");
|
||||
});
|
||||
});
|
||||
|
||||
describe("incrementalFilter — per-shape filter grammar", () => {
|
||||
const start = new Date("2026-06-01T15:30:00.000Z");
|
||||
|
||||
it("filters samples on sample_time.physical_time (RFC-3339)", () => {
|
||||
expect(incrementalFilter(GOOGLE_HEALTH_DATA_TYPES.weight, start)).toEqual({
|
||||
field: "weight.sample_time.physical_time",
|
||||
bound: "2026-06-01T15:30:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters daily summaries on .date (YYYY-MM-DD)", () => {
|
||||
expect(
|
||||
incrementalFilter(GOOGLE_HEALTH_DATA_TYPES.oxygenSaturation, start),
|
||||
).toEqual({
|
||||
field: "daily_oxygen_saturation.date",
|
||||
bound: "2026-06-01",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters sleep on interval.end_time — the only legal sleep time field", () => {
|
||||
expect(incrementalFilter(GOOGLE_HEALTH_DATA_TYPES.sleep, start)).toEqual({
|
||||
field: "sleep.interval.end_time",
|
||||
bound: "2026-06-01T15:30:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters exercise on interval.civil_start_time with an offset-less civil bound in the user's zone", () => {
|
||||
expect(
|
||||
incrementalFilter(
|
||||
GOOGLE_HEALTH_DATA_TYPES.exercise,
|
||||
start,
|
||||
"Europe/Berlin",
|
||||
),
|
||||
).toEqual({
|
||||
field: "exercise.interval.civil_start_time",
|
||||
// 15:30Z = 17:30 Berlin wall clock (CEST) — no Z, no offset.
|
||||
bound: "2026-06-01T17:30:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to build a list filter for a rollup type", () => {
|
||||
expect(() =>
|
||||
incrementalFilter(GOOGLE_HEALTH_DATA_TYPES.steps, start),
|
||||
).toThrow(/dailyRollUp/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCivilBound", () => {
|
||||
it("forms the bound in UTC when no zone is known", () => {
|
||||
expect(formatCivilBound(new Date("2026-06-01T15:30:45.000Z"))).toBe(
|
||||
"2026-06-01T15:30:45",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkCivilRange — dailyRollUp 90-day slicing", () => {
|
||||
it("returns a single chunk for a range under the cap", () => {
|
||||
expect(
|
||||
chunkCivilRange(
|
||||
{ year: 2026, month: 6, day: 1 },
|
||||
{ year: 2026, month: 6, day: 8 },
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
start: { year: 2026, month: 6, day: 1 },
|
||||
end: { year: 2026, month: 6, day: 8 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("slices a long range into closed-open ≤90-day chunks with no gap or overlap", () => {
|
||||
const chunks = chunkCivilRange(
|
||||
{ year: 2026, month: 1, day: 1 },
|
||||
{ year: 2026, month: 8, day: 1 },
|
||||
);
|
||||
expect(chunks.length).toBe(3); // 212 days → 90 + 90 + 32
|
||||
expect(chunks[0]!.start).toEqual({ year: 2026, month: 1, day: 1 });
|
||||
// Each chunk starts exactly where the previous one ends (closed-open).
|
||||
expect(chunks[1]!.start).toEqual(chunks[0]!.end);
|
||||
expect(chunks[2]!.start).toEqual(chunks[1]!.end);
|
||||
expect(chunks[2]!.end).toEqual({ year: 2026, month: 8, day: 1 });
|
||||
});
|
||||
|
||||
it("returns nothing for an empty or inverted range", () => {
|
||||
expect(
|
||||
chunkCivilRange(
|
||||
{ year: 2026, month: 6, day: 8 },
|
||||
{ year: 2026, month: 6, day: 1 },
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
+575
-409
File diff suppressed because it is too large.
Load diff
@@ -5,19 +5,21 @@
|
||||
* `activity_and_fitness.readonly` Restricted bundle and upserts each mapped
|
||||
* daily total as `source = GOOGLE_HEALTH`:
|
||||
*
|
||||
* - steps → ACTIVITY_STEPS (count)
|
||||
* - distance → WALKING_RUNNING_DISTANCE (metres)
|
||||
* - active-energy-burned→ ACTIVE_ENERGY_BURNED (kcal — ACTIVE portion only)
|
||||
* - floors → FLIGHTS_CLIMBED (count)
|
||||
* - vo2-max → VO2_MAX (mL/(kg·min); daily latest-wins)
|
||||
* - steps → ACTIVITY_STEPS (countSum)
|
||||
* - distance → WALKING_RUNNING_DISTANCE (millimetersSum → m)
|
||||
* - active-energy-burned → ACTIVE_ENERGY_BURNED (kcalSum — ACTIVE portion only)
|
||||
* - floors → FLIGHTS_CLIMBED (countSum)
|
||||
* - daily-vo2-max → VO2_MAX (mL/(kg·min); daily latest-wins)
|
||||
*
|
||||
* These are per-day summaries (one value per calendar day). The externalId is
|
||||
* minted with the `stats:` daily-total prefix — `stats:<fieldTag>:<YYYY-MM-DD>`
|
||||
* — so a re-fetched day OVERWRITES the existing row rather than minting a
|
||||
* duplicate, matching the Apple-Health `stats:<HK>:<YYYY-MM-DD>` overwrite
|
||||
* contract. A day of rest legitimately records 0 steps / 0 floors / 0 active
|
||||
* kcal, so the cumulative mappers preserve a zero; VO2 max stays strictly
|
||||
* positive.
|
||||
* The four cumulative types read through `POST :dailyRollUp` with
|
||||
* `windowSizeDays: 1` (their `list` surface returns minute-grain buckets, not
|
||||
* daily totals — and floors has no list method at all); VO2 max is a daily
|
||||
* summary read through list with a `.date` filter. The externalId is minted
|
||||
* with the `stats:` daily-total prefix — `stats:<fieldTag>:<YYYY-MM-DD>` — so a
|
||||
* re-fetched day OVERWRITES the existing row rather than minting a duplicate,
|
||||
* matching the Apple-Health `stats:<HK>:<YYYY-MM-DD>` overwrite contract. A day
|
||||
* of rest legitimately records 0 steps / 0 floors / 0 active kcal, so the
|
||||
* rollup mapper preserves a zero; VO2 max stays strictly positive.
|
||||
*
|
||||
* A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection
|
||||
* connected) — the Restricted bundles are granted independently.
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
GOOGLE_HEALTH_DATA_TYPES,
|
||||
type GoogleHealthDataType,
|
||||
type GoogleHealthMappedMeasurement,
|
||||
fetchDailyRollUp,
|
||||
fetchDataPoints,
|
||||
mapActiveEnergy,
|
||||
mapDistance,
|
||||
@@ -46,14 +49,12 @@ import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
/** One mappable activity metric: its data-type encoding + the mapper + a verb. */
|
||||
interface ActivityResource {
|
||||
dataType: GoogleHealthDataType;
|
||||
map: (
|
||||
point: Record<string, unknown>,
|
||||
tz?: string,
|
||||
) => GoogleHealthMappedMeasurement[];
|
||||
map: (point: Record<string, unknown>) => GoogleHealthMappedMeasurement[];
|
||||
verb: string;
|
||||
}
|
||||
|
||||
const ACTIVITY_RESOURCES: ActivityResource[] = [
|
||||
/** The four cumulative daily totals — read via `POST :dailyRollUp`. */
|
||||
const ROLLUP_RESOURCES: ActivityResource[] = [
|
||||
{
|
||||
dataType: GOOGLE_HEALTH_DATA_TYPES.steps,
|
||||
map: mapSteps,
|
||||
@@ -74,11 +75,6 @@ const ACTIVITY_RESOURCES: ActivityResource[] = [
|
||||
map: mapFloors,
|
||||
verb: "fetchFloors",
|
||||
},
|
||||
{
|
||||
dataType: GOOGLE_HEALTH_DATA_TYPES.vo2Max,
|
||||
map: mapVo2Max,
|
||||
verb: "fetchVo2Max",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -98,10 +94,10 @@ export async function syncUserActivity(
|
||||
const tokenInfo = await getValidToken(userId);
|
||||
if (!tokenInfo) return 0;
|
||||
|
||||
// A cumulative daily total whose `interval.start_time` sits at the user's
|
||||
// local midnight can carry an offset-less civil anchor; resolve the user's
|
||||
// stored zone so the day-key lands on the correct civil day rather than the
|
||||
// process zone's.
|
||||
// The dailyRollUp request range is civil and user-local; resolve the user's
|
||||
// stored zone so the range bounds land on the correct civil days rather than
|
||||
// the process zone's. (The response day-key comes from each window's own
|
||||
// `civilStartTime.date`, tz-independent.)
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
|
||||
// Cycle-wide watermark snapshotted once by `syncUserGoogleHealth`; undefined
|
||||
@@ -109,14 +105,14 @@ export async function syncUserActivity(
|
||||
const start = opts.start;
|
||||
|
||||
let imported = 0;
|
||||
for (const resource of ACTIVITY_RESOURCES) {
|
||||
for (const resource of ROLLUP_RESOURCES) {
|
||||
let points: Record<string, unknown>[];
|
||||
try {
|
||||
points = await fetchDataPoints(
|
||||
points = await fetchDailyRollUp(
|
||||
resource.dataType,
|
||||
tokenInfo.accessToken,
|
||||
resource.verb,
|
||||
{ start },
|
||||
{ start, tz },
|
||||
);
|
||||
} catch (err) {
|
||||
imported += await handleCollectionFetchError(resource.verb, userId, err);
|
||||
@@ -125,7 +121,7 @@ export async function syncUserActivity(
|
||||
|
||||
const readings: GoogleHealthMeasurementUpsert[] = [];
|
||||
for (const point of points) {
|
||||
for (const m of resource.map(point, tz)) {
|
||||
for (const m of resource.map(point)) {
|
||||
readings.push({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
@@ -142,6 +138,35 @@ export async function syncUserActivity(
|
||||
).imported;
|
||||
}
|
||||
|
||||
// VO2 max — a daily summary (list + `.date` filter), not a rollup type.
|
||||
try {
|
||||
const points = await fetchDataPoints(
|
||||
GOOGLE_HEALTH_DATA_TYPES.vo2Max,
|
||||
tokenInfo.accessToken,
|
||||
"fetchVo2Max",
|
||||
{ start },
|
||||
);
|
||||
const readings: GoogleHealthMeasurementUpsert[] = [];
|
||||
for (const point of points) {
|
||||
for (const m of mapVo2Max(point)) {
|
||||
readings.push({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
externalId: externalIdFor(m),
|
||||
});
|
||||
}
|
||||
}
|
||||
imported += (
|
||||
await upsertGoogleHealthMeasurements(userId, readings, {
|
||||
deferRollup: opts.deferRollup,
|
||||
})
|
||||
).imported;
|
||||
} catch (err) {
|
||||
imported += await handleCollectionFetchError("fetchVo2Max", userId, err);
|
||||
}
|
||||
|
||||
// `markSynced` is owned by the orchestrator (`syncUserGoogleHealth`).
|
||||
annotate({
|
||||
action: { name: "googleHealth.activity.sync", details: { imported } },
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
* `health_metrics_and_measurements.readonly` Restricted bundle and upserts each
|
||||
* mapped reading as `source = GOOGLE_HEALTH`:
|
||||
*
|
||||
* - weight → WEIGHT
|
||||
* - body-fat → BODY_FAT
|
||||
* - oxygen-saturation → OXYGEN_SATURATION
|
||||
* - heart-rate-variability → HEART_RATE_VARIABILITY (SDNN slot; see mapper)
|
||||
* - daily-resting-HR → RESTING_HEART_RATE
|
||||
* - respiratory-rate → RESPIRATORY_RATE
|
||||
* - heart-rate (intraday) → PULSE
|
||||
* - height → User.heightCm (profile seed, NOT a Measurement)
|
||||
* - weight → WEIGHT
|
||||
* - body-fat → BODY_FAT
|
||||
* - daily-oxygen-saturation → OXYGEN_SATURATION
|
||||
* - daily-heart-rate-variability → HEART_RATE_VARIABILITY (SDNN slot; see mapper)
|
||||
* - daily-resting-heart-rate → RESTING_HEART_RATE
|
||||
* - daily-respiratory-rate → RESPIRATORY_RATE
|
||||
* - heart-rate (intraday) → PULSE
|
||||
* - height → User.heightCm (profile seed, NOT a Measurement)
|
||||
*
|
||||
* Each data point yields at most one Measurement row, disambiguated by the
|
||||
* per-point anchor + field-tag in the externalId (`<anchor>:<fieldTag>`) so a
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
mapBodyFat,
|
||||
mapHeartRate,
|
||||
mapHeartRateVariability,
|
||||
mapHeightCm,
|
||||
mapHeight,
|
||||
mapOxygenSaturation,
|
||||
mapRespiratoryRate,
|
||||
mapRestingHeartRate,
|
||||
@@ -160,11 +160,20 @@ export async function syncUserMetrics(
|
||||
await handleCollectionFetchError("fetchHeight", userId, err);
|
||||
}
|
||||
|
||||
// Latest non-null parsed height wins (the points are time-ordered ascending).
|
||||
// Latest sample wins — picked by max(sampledAt) explicitly, because the
|
||||
// list response is ordered DESCENDING by time (a "last row wins" loop would
|
||||
// seed the OLDEST height). A sample with no parseable instant only wins
|
||||
// over nothing.
|
||||
let heightCm: number | null = null;
|
||||
let heightAt = -Infinity;
|
||||
for (const point of heightPoints) {
|
||||
const cm = mapHeightCm(point);
|
||||
if (cm !== null) heightCm = cm;
|
||||
const sample = mapHeight(point);
|
||||
if (sample === null) continue;
|
||||
const at = sample.sampledAt?.getTime() ?? -Infinity;
|
||||
if (heightCm === null || at > heightAt) {
|
||||
heightCm = sample.cm;
|
||||
heightAt = at;
|
||||
}
|
||||
}
|
||||
if (heightCm !== null) {
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
@@ -37,7 +37,10 @@ export async function syncUserWorkout(
|
||||
if (!tokenInfo) return 0;
|
||||
|
||||
// Session start/end can arrive offset-less; anchor them against the user's
|
||||
// stored zone rather than the process zone.
|
||||
// stored zone rather than the process zone. The zone also shapes the
|
||||
// incremental filter: exercise sessions filter on
|
||||
// `exercise.interval.civil_start_time` with an offset-less civil bound, which
|
||||
// must be the watermark's wall clock in the USER'S zone.
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
|
||||
// Cycle-wide watermark snapshotted once by `syncUserGoogleHealth`; undefined
|
||||
@@ -50,7 +53,7 @@ export async function syncUserWorkout(
|
||||
GOOGLE_HEALTH_DATA_TYPES.exercise,
|
||||
tokenInfo.accessToken,
|
||||
"fetchExercise",
|
||||
{ start, pageSize: GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE },
|
||||
{ start, pageSize: GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE, tz },
|
||||
);
|
||||
} catch (err) {
|
||||
return handleCollectionFetchError("fetchExercise", userId, err);
|
||||
|
||||
Reference in new issue
Block a user