mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #405 from MBombeck/release/v1.27.4
Release v1.27.4 — Google Health sync fixed against the live API
This commit is contained in:
11 files changed
+1342
-649
No files matched your search
@@ -2,6 +2,17 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.27.4] — 2026-07-05 — Google Health sync fixed against the live API
|
||||
|
||||
### Fixed
|
||||
|
||||
- The Google Health sync now actually imports data. Against the live service the v4 responses differ from what the connection was built against: payload fields arrive in a different naming form, large numbers arrive as text, and several data types only answer on other read methods. Steps, distance, floors, and active energy now read as true daily totals; sleep, workouts, blood oxygen, HRV, respiratory rate, and cardio fitness use the read method and data type the service accepts, so their requests no longer fail. Weight and height convert from the service's units correctly.
|
||||
- When Google rejects a request, the sync log now carries the service's own error message (shortened, without credentials), so a failing setup can be diagnosed from the integration status instead of a bare status code.
|
||||
|
||||
### Added
|
||||
|
||||
- The Google Health connection test can return a structure probe: one sample request per data type, reduced to field names and value kinds — never actual readings. Useful when reporting sync issues.
|
||||
|
||||
## [1.27.3] — 2026-07-05 — Briefing resilience and Insights readability
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.27.3
|
||||
version: 1.27.4
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.27.3",
|
||||
"version": "1.27.4",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.27.0";
|
||||
/* @sw-version-fallback */ "v1.27.4";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -5,7 +5,15 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getValidToken } from "@/lib/google-health/sync";
|
||||
import { GOOGLE_HEALTH_API_BASE } from "@/lib/google-health/client";
|
||||
import {
|
||||
GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE,
|
||||
GOOGLE_HEALTH_API_BASE,
|
||||
GOOGLE_HEALTH_DATA_TYPES,
|
||||
fetchDailyRollUp,
|
||||
fetchDataPoints,
|
||||
} from "@/lib/google-health/client";
|
||||
import { GoogleHealthApiError } from "@/lib/google-health/response-classifier";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -43,8 +51,106 @@ function categoriseHttpStatus(status: number): CategorisedError {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce an arbitrary JSON value to its STRUCTURE: object keys survive, every
|
||||
* leaf collapses to its `typeof` label (`"string"`, `"number"`, …; null →
|
||||
* `"null"`), arrays keep only their first element's structure. No health value,
|
||||
* timestamp, or identifier survives — the output is safe for a self-hoster to
|
||||
* paste into a public diagnostics thread.
|
||||
*/
|
||||
function describeStructure(v: unknown, depth = 0): unknown {
|
||||
if (depth > 8) return "…";
|
||||
if (v === null) return "null";
|
||||
if (Array.isArray(v)) {
|
||||
return v.length > 0 ? [describeStructure(v[0], depth + 1)] : [];
|
||||
}
|
||||
if (typeof v === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(v as Record<string, unknown>).map(([k, val]) => [
|
||||
k,
|
||||
describeStructure(val, depth + 1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return typeof v;
|
||||
}
|
||||
|
||||
type ProbeResult =
|
||||
| { ok: true; count: number; structure: unknown }
|
||||
| {
|
||||
ok: false;
|
||||
httpStatus: number | null;
|
||||
classification: string | null;
|
||||
detail: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch ONE recent page/window per data type and reduce the first data point to
|
||||
* its structure. Errors are captured per type (a 400's AIP-193 detail included)
|
||||
* so a broken type doesn't hide the others — that per-type verdict is the whole
|
||||
* point of the probe.
|
||||
*/
|
||||
async function runStructureProbe(
|
||||
accessToken: string,
|
||||
tz: string | undefined,
|
||||
): Promise<Record<string, ProbeResult>> {
|
||||
const now = Date.now();
|
||||
const listStart = new Date(now - 14 * 24 * 60 * 60 * 1000);
|
||||
const rollupStart = new Date(now - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const results: Record<string, ProbeResult> = {};
|
||||
for (const [name, dataType] of Object.entries(GOOGLE_HEALTH_DATA_TYPES)) {
|
||||
try {
|
||||
let points: Record<string, unknown>[];
|
||||
if (dataType.timeField === "rollup") {
|
||||
points = await fetchDailyRollUp(
|
||||
dataType,
|
||||
accessToken,
|
||||
`probe:${name}`,
|
||||
{
|
||||
start: rollupStart,
|
||||
tz,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const pageSize =
|
||||
dataType.timeField === "sessionEnd" ||
|
||||
dataType.timeField === "civilStart"
|
||||
? GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE
|
||||
: 5;
|
||||
points = await fetchDataPoints(dataType, accessToken, `probe:${name}`, {
|
||||
start: listStart,
|
||||
pageSize,
|
||||
maxPages: 1,
|
||||
tz,
|
||||
});
|
||||
}
|
||||
results[name] = {
|
||||
ok: true,
|
||||
count: points.length,
|
||||
structure: points.length > 0 ? describeStructure(points[0]) : null,
|
||||
};
|
||||
} catch (e) {
|
||||
results[name] =
|
||||
e instanceof GoogleHealthApiError
|
||||
? {
|
||||
ok: false,
|
||||
httpStatus: e.httpStatus ?? null,
|
||||
classification: e.classification,
|
||||
detail: e.upstreamError ?? null,
|
||||
}
|
||||
: {
|
||||
ok: false,
|
||||
httpStatus: null,
|
||||
classification: null,
|
||||
detail: "fetch_failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.google-health.test" } });
|
||||
|
||||
@@ -55,6 +161,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Flag-only payload — `{ "probe": "structure" }` switches to the per-type
|
||||
// structure probe; anything else runs the plain connection check. Cap the
|
||||
// parse cost (mirrors safeJson maxBytes).
|
||||
let structureProbe = false;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
if (raw.length > 64 * 1024) {
|
||||
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
|
||||
}
|
||||
const body = JSON.parse(raw);
|
||||
structureProbe = body?.probe === "structure";
|
||||
} catch {
|
||||
// no body provided -> plain connection check
|
||||
}
|
||||
|
||||
const tokenInfo = await getValidToken(user.id);
|
||||
if (!tokenInfo) {
|
||||
return apiError("Google Health not connected", 422, {
|
||||
@@ -62,6 +183,22 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (structureProbe) {
|
||||
// Per-data-type structure probe: one page/window per type, reduced to field
|
||||
// names + leaf types (never values) so a self-hoster can paste the output
|
||||
// as diagnostics without leaking a single reading.
|
||||
annotate({
|
||||
action: { name: "integrations.google-health.structure_probe" },
|
||||
});
|
||||
const tz = await resolveUserTimezone(user.id);
|
||||
const types = await runStructureProbe(tokenInfo.accessToken, tz);
|
||||
return apiSuccess({
|
||||
probe: "structure",
|
||||
probedAt: new Date().toISOString(),
|
||||
types,
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await prisma.googleHealthConnection.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { lastSyncedAt: true },
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+641
-411
File diff suppressed because it is too large.
Load diff
@@ -4,74 +4,86 @@ Single source of truth for the Google Health API v4 data-type → HealthLog
|
||||
`Measurement` mapping. Keep this in sync with the per-type mappers in `client.ts`.
|
||||
Every row ingests server-side with `source = GOOGLE_HEALTH` and
|
||||
`externalId = <anchor>:<fieldTag>` (the anchor is the data point's sample time for
|
||||
spot readings, its civil date for daily summaries, or — for INTERVAL types
|
||||
(steps/distance/energy/floors, sleep, exercise) — its `interval.civil_start_time`
|
||||
anchored at UTC-midday, falling back to `interval.start_time` only when the civil
|
||||
date is absent; the field-tag disambiguates the metric).
|
||||
spot readings or its civil date for daily summaries; the field-tag disambiguates
|
||||
the metric). The cumulative daily totals use the `stats:<fieldTag>:<YYYY-MM-DD>`
|
||||
overwrite shape instead.
|
||||
|
||||
Google Health is a **separate, coexisting provider** from the classic Fitbit
|
||||
integration (`src/lib/fitbit/*`, `source = FITBIT`). They never share a token,
|
||||
connection row, source enum, or cookie.
|
||||
|
||||
## Time anchor by grain
|
||||
## The three encodings (casing)
|
||||
|
||||
Google Health exposes three time-anchor shapes. The incremental `filter` and the
|
||||
read-time `measuredAt`/externalId resolution must target the right one or the
|
||||
incremental sync stalls (a `sample_time` filter 400s/empties for an INTERVAL type):
|
||||
Each data type has THREE on-the-wire encodings, all pinned in
|
||||
`GOOGLE_HEALTH_DATA_TYPES` so a fetcher can never mix them up:
|
||||
|
||||
- **sample** — spot reading; anchor `{filter}.sample_time.physical_time`.
|
||||
- **date** — daily-summary metric (SpO2, HRV, RHR, respiratory rate, VO2 max);
|
||||
anchor `{filter}.date` (a civil date / `{year,month,day}`).
|
||||
- **interval** — INTERVAL type (steps/distance/energy/floors daily totals, plus
|
||||
sleep + exercise sessions); the incremental `filter` targets
|
||||
`{filter}.interval.start_time`, but the read-time `measuredAt`/day-key derive
|
||||
from `{filter}.interval.civil_start_time` anchored at UTC-midday, so a daily
|
||||
total keys on the civil day (aligning with the Apple/Fitbit `stats:` contract);
|
||||
the physical `start_time` is the fallback only when civil is absent.
|
||||
- **request path** — kebab-case (`body-fat`, `daily-resting-heart-rate`).
|
||||
- **`filter` parameter** — snake_case (`body_fat.sample_time.physical_time`).
|
||||
snake_case exists ONLY here.
|
||||
- **response payload** — camelCase. The `DataPoint` value is a union keyed by
|
||||
the camelCase type name (`bodyFat`, `dailyRestingHeartRate`,
|
||||
`activeEnergyBurned`, …) with camelCase nested objects
|
||||
(`sampleTime.physicalTime`, `interval.startTime`, `civilStartTime.date`).
|
||||
|
||||
Transport docs: `developers.google.com/health` (v4). The per-type value-field
|
||||
JSON is **NOT fully published** — the mappers read a small set of candidate value
|
||||
paths defensively (`firstNumber` walks each shape and takes the first
|
||||
finite-positive hit). **Re-verify against a live test account at build** and
|
||||
tighten the extractors.
|
||||
proto3 int64 fields arrive as JSON **strings** (`"12345"`): `steps.countSum`,
|
||||
`heartRate.beatsPerMinute`, `dailyRestingHeartRate.beatsPerMinute`,
|
||||
`dailyRespiratoryRate.dailyRespiratoryRateBpm`, `distance.millimetersSum`,
|
||||
`floors.countSum`, `exercise.metricsSummary.averageHeartRateBeatsPerMinute`, the
|
||||
sleep summary minutes. Every numeric extractor coerces numeric strings before
|
||||
the finite check.
|
||||
|
||||
## Read method
|
||||
|
||||
Every launch data type supports `dataPoints.list` (`readMethod: "list"`, the only
|
||||
method the transport uses). The rollup-only types (`total-calories`,
|
||||
`calories-in-heart-rate-zone`, which have no `:list`) are deliberately **not** in
|
||||
the launch set: active energy already covers the active-calories slot, and
|
||||
`total-calories` folds in BMR and needs a modelling decision before it lands as a
|
||||
metric. Adding either later means adding a `:dailyRollUp` reader together with it.
|
||||
Two read methods are in use — NOT every type supports `dataPoints.list`:
|
||||
|
||||
## Casing gotcha
|
||||
- **`dataPoints.list`** (GET, `nextPageToken` pagination) — spot samples
|
||||
(weight, body-fat, heart-rate, height), daily summaries
|
||||
(daily-oxygen-saturation, daily-heart-rate-variability,
|
||||
daily-resting-heart-rate, daily-respiratory-rate, daily-vo2-max), and the
|
||||
session types (sleep, exercise; pageSize default & max **25**).
|
||||
- **`dataPoints:dailyRollUp`** (POST, `windowSizeDays: 1`) — the cumulative
|
||||
activity totals (steps, distance, active-energy-burned, floors). Their list
|
||||
surface returns minute-grain observation buckets (≈1440/day), NOT daily
|
||||
totals — and **floors has no list method at all**. The request range is
|
||||
CivilDateTime, closed-open, user-local, max **90 days** per request; the
|
||||
client chunks longer ranges and pins the full-sync horizon
|
||||
(`GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS`).
|
||||
|
||||
The data-type id is **kebab-case in the request path** (`body-fat`) and
|
||||
**snake_case in the `filter` predicate** (`body_fat`). `GOOGLE_HEALTH_DATA_TYPES`
|
||||
pins both forms per type so a fetcher can never encode the wrong one.
|
||||
Legal incremental `filter` fields are per-shape — anything else is an HTTP 400
|
||||
`INVALID_ARGUMENT`:
|
||||
|
||||
| Shape | Filter field | Bound format |
|
||||
| ------------- | ------------------------------------------------------- | ------------------------------ |
|
||||
| Sample | `{type}.sample_time.physical_time` | RFC-3339 |
|
||||
| Daily summary | `{type}.date` | `YYYY-MM-DD` |
|
||||
| Sleep | `sleep.interval.end_time` (the ONLY legal time field) | RFC-3339 |
|
||||
| Session | `{type}.interval.civil_start_time` (the ONLY legal one) | offset-less civil ISO (no `Z`) |
|
||||
|
||||
The exercise civil bound is derived from the watermark in the USER'S zone.
|
||||
|
||||
## Health-metrics bundle
|
||||
|
||||
Scope: `googlehealth.health_metrics_and_measurements.readonly`. Identifiers
|
||||
verified against the 2026 contract (API-RESEARCH §3/§4).
|
||||
Scope: `googlehealth.health_metrics_and_measurements.readonly`.
|
||||
|
||||
| Data type (path / filter) | MeasurementType | Unit | fieldTag | Grain | Note |
|
||||
| ------------------------------------------------------- | ------------------------ | ----------- | ----------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `weight` / `weight` | `WEIGHT` | kg | `weight` | sample | Picker ranks a real Withings scale above Google Health. |
|
||||
| `body-fat` / `body_fat` | `BODY_FAT` | % | `body_fat` | sample | |
|
||||
| `oxygen-saturation` / `oxygen_saturation` | `OXYGEN_SATURATION` | % | `spo2` | daily | Already 0–100. |
|
||||
| `heart-rate-variability` / `heart_rate_variability` | `HEART_RATE_VARIABILITY` | ms | `hrv` | daily | **Decision:** SDNN slot (Apple-comparable), NOT `HRV_RMSSD`. Google reports an RMSSD-style nightly HRV — confirm the estimator at build and reconsider if warranted. |
|
||||
| `daily-resting-heart-rate` / `daily_resting_heart_rate` | `RESTING_HEART_RATE` | bpm | `rhr` | daily | |
|
||||
| `respiratory-rate` / `respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | Identifier appears in spec prose; confirm `:list` support at build (API-RESEARCH OPEN §4). |
|
||||
| `heart-rate` / `heart_rate` | `PULSE` | bpm | `hr` | sample | Intraday spot HR. |
|
||||
| `height` / `height` | `User.heightCm` | cm | — | sample | Profile seed — written to `User.heightCm` ONLY when null; never minted as a Measurement (WHOOP `mapBody` pattern). m→cm when reported in metres. |
|
||||
| Data type (path) | Payload value read | MeasurementType | Unit | fieldTag | Grain | Note |
|
||||
| ------------------------------ | ------------------------------------------------------------------- | ------------------------ | ----------- | ----------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `weight` | `weight.weightGrams` ÷ 1000 | `WEIGHT` | kg | `weight` | sample | Grams on the wire. Picker ranks a real Withings scale above Google Health. |
|
||||
| `body-fat` | `bodyFat.percentage` | `BODY_FAT` | % | `body_fat` | sample | Union key is camelCase `bodyFat`. |
|
||||
| `daily-oxygen-saturation` | `dailyOxygenSaturation.averagePercentage` | `OXYGEN_SATURATION` | % | `spo2` | daily | The bare `oxygen-saturation` type is per-SAMPLE and rejects a `.date` filter. |
|
||||
| `daily-heart-rate-variability` | `dailyHeartRateVariability.averageHeartRateVariabilityMilliseconds` | `HEART_RATE_VARIABILITY` | ms | `hrv` | daily | **Decision:** SDNN slot (Apple-comparable), NOT `HRV_RMSSD`. The daily field is an unlabelled "average HRV ms" — re-confirm estimator live. |
|
||||
| `daily-resting-heart-rate` | `dailyRestingHeartRate.beatsPerMinute` (int64 string) | `RESTING_HEART_RATE` | bpm | `rhr` | daily | |
|
||||
| `daily-respiratory-rate` | `dailyRespiratoryRate.dailyRespiratoryRateBpm` (int64 string) | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | `respiratory-rate` does NOT exist in the catalogue. |
|
||||
| `heart-rate` | `heartRate.beatsPerMinute` (int64 string) | `PULSE` | bpm | `hr` | sample | Intraday spot HR; per-minute volume — consider the 14-day-max rollup if PULSE should stay coarse. |
|
||||
| `height` | `height.heightMeters` × 100 | `User.heightCm` | cm | — | sample | Profile seed — written ONLY when null; never a Measurement. List order is DESCENDING → the seed picks max(sampleTime) explicitly. |
|
||||
|
||||
Daily-summary `date` fields are `{year,month,day}` objects, anchored at
|
||||
UTC-midday so a timezone shift can't roll the civil day. Every value passes a
|
||||
finite + strictly-positive guard (the launch metrics are all positive — a
|
||||
zero/NaN is a garbage/empty reading and is dropped).
|
||||
|
||||
Skin temperature (`daily-sleep-temperature-derivations`) is intentionally **not**
|
||||
in the launch set — see **Deferred** below.
|
||||
|
||||
Every value passes a finite + strictly-positive guard (the launch metrics are all
|
||||
positive — a zero/NaN is a garbage/empty reading and is dropped).
|
||||
|
||||
## Deferred (Google Health Q3-2026 roadmap — slots exist, NOT in launch set)
|
||||
|
||||
`blood-glucose` → `BLOOD_GLUCOSE`, `blood-pressure` → `BLOOD_PRESSURE_SYS`+`_DIA`,
|
||||
@@ -83,44 +95,62 @@ model before it can land — mapping it into `WRIST_TEMPERATURE` would store a d
|
||||
as an absolute temperature. ECG/IRN light up when Google ships the data types and
|
||||
a reader is added together with its Restricted scopes.
|
||||
|
||||
## Activity bundle — daily cumulative
|
||||
## Activity bundle — daily cumulative (dailyRollUp)
|
||||
|
||||
Scope: `googlehealth.activity_and_fitness.readonly`. Each is a per-day summary
|
||||
(one value per calendar day). The externalId carries the `stats:` daily-total
|
||||
prefix — `stats:<fieldTag>:<YYYY-MM-DD>` — so a re-fetched day **overwrites** in
|
||||
place rather than minting a duplicate, matching the Apple-Health
|
||||
`stats:<HK>:<YYYY-MM-DD>` daily-total overwrite contract. The running totals
|
||||
(steps/distance/floors/active-energy) **preserve a 0** (a rest day is real data,
|
||||
not a gap); VO2 max stays strictly positive (daily latest-wins).
|
||||
Scope: `googlehealth.activity_and_fitness.readonly`. The four running totals
|
||||
read through `POST :dailyRollUp` with `windowSizeDays: 1`; each aggregate window
|
||||
carries `civilStartTime`/`civilEndTime` (CivilDateTime objects —
|
||||
`{date:{year,month,day}, time:{…}}`) and a union keyed by the camelCase type
|
||||
name with the `*Sum` rollup fields. The day key comes from
|
||||
`civilStartTime.date`, anchored at UTC-midday. The externalId carries the
|
||||
`stats:` daily-total prefix — `stats:<fieldTag>:<YYYY-MM-DD>` — so a re-fetched
|
||||
day **overwrites** in place, matching the Apple-Health
|
||||
`stats:<HK>:<YYYY-MM-DD>` overwrite contract. The running totals **preserve a
|
||||
0** (a rest day is real data, not a gap).
|
||||
|
||||
| Data type (path / filter) | MeasurementType | Unit | fieldTag | Note |
|
||||
| ----------------------------------------------- | -------------------------- | ----------- | --------------- | ------------------------------------------------------------------- |
|
||||
| `steps` / `steps` | `ACTIVITY_STEPS` | steps | `steps` | daily total; 0 valid |
|
||||
| `distance` / `distance` | `WALKING_RUNNING_DISTANCE` | m | `distance` | metres (km → m when reported in km) |
|
||||
| `active-energy-burned` / `active_energy_burned` | `ACTIVE_ENERGY_BURNED` | kcal | `active_energy` | **ACTIVE portion only** — NOT `total-calories` (which folds in BMR) |
|
||||
| `floors` / `floors` | `FLIGHTS_CLIMBED` | flights | `floors` | daily total; 0 valid |
|
||||
| `vo2-max` / `vo2_max` | `VO2_MAX` | mL/(kg·min) | `vo2_max` | daily latest-wins; strictly positive |
|
||||
| Data type (path) | Rollup value read | MeasurementType | Unit | fieldTag | Note |
|
||||
| ---------------------- | ---------------------------------------------- | -------------------------- | ------- | --------------- | ------------------------------------------------------------------- |
|
||||
| `steps` | `steps.countSum` (int64 string) | `ACTIVITY_STEPS` | steps | `steps` | daily total; 0 valid |
|
||||
| `distance` | `distance.millimetersSum` (int64 string) ÷1000 | `WALKING_RUNNING_DISTANCE` | m | `distance` | millimetres on the wire |
|
||||
| `active-energy-burned` | `activeEnergyBurned.kcalSum` (number) | `ACTIVE_ENERGY_BURNED` | kcal | `active_energy` | **ACTIVE portion only** — NOT `total-calories` (which folds in BMR) |
|
||||
| `floors` | `floors.countSum` (int64 string) | `FLIGHTS_CLIMBED` | flights | `floors` | daily total; 0 valid; **no list method** — rollup is the only read |
|
||||
|
||||
VO2 max is NOT a rollup type — `daily-vo2-max` is a daily summary read via list
|
||||
(`daily_vo2_max.date` filter), value `dailyVo2Max.vo2Max`, strictly positive,
|
||||
daily latest-wins, same `stats:`-style per-day overwrite key (`vo2_max`).
|
||||
|
||||
## Sleep bundle
|
||||
|
||||
Scope: `googlehealth.sleep.readonly`. A sleep session carries per-stage segments
|
||||
(stage label + start + end). HealthLog stores one `SLEEP_DURATION` row per stage
|
||||
SEGMENT, `measuredAt = that segment's END instant`, harmonised onto the shared
|
||||
`SleepStage` enum the night-total + hypnogram readers consume. externalId =
|
||||
`<session-anchor>:sleep_<stage>:<i>` (session end ISO instant + indexed segment),
|
||||
so a re-scored night overwrites in place. Stage map: `light → CORE` ("light" ↔
|
||||
Apple "core" shallow-NREM band), `deep → DEEP`, `rem → REM`,
|
||||
`awake`/`wake`/`restless → AWAKE`, `in_bed → IN_BED`, classic `asleep → ASLEEP`.
|
||||
Unknown stage labels are skipped, not mis-bucketed.
|
||||
Scope: `googlehealth.sleep.readonly`. Sleep sessions filter **only** on
|
||||
`sleep.interval.end_time` (a night is fetched when it ENDS after the cursor).
|
||||
The payload: `sleep.interval` (SessionTimeInterval — `startTime`/`endTime`
|
||||
RFC-3339), `sleep.type` (`CLASSIC | STAGES`), `sleep.stages[]`
|
||||
(`{startTime, startUtcOffset, endTime, endUtcOffset, type}`), and
|
||||
`sleep.summary` (`minutesAsleep`/`minutesAwake`, int64 strings). HealthLog
|
||||
stores one `SLEEP_DURATION` row per stage SEGMENT, `measuredAt = that segment's
|
||||
END instant`, harmonised onto the shared `SleepStage` enum the night-total +
|
||||
hypnogram readers consume. externalId = `<session-anchor>:sleep_<stage>:<i>`
|
||||
(session `interval.endTime` ISO instant + indexed segment), so a re-scored night
|
||||
overwrites in place. Stage map (`SLEEP_STAGE_TYPE` enum, lowercased):
|
||||
`LIGHT → CORE` ("light" ↔ Apple "core" shallow-NREM band), `DEEP → DEEP`,
|
||||
`REM → REM`, `AWAKE`/`RESTLESS → AWAKE`, classic `ASLEEP → ASLEEP`.
|
||||
`SLEEP_STAGE_TYPE_UNSPECIFIED` / unknown labels are skipped, not mis-bucketed.
|
||||
|
||||
## Exercise bundle — Workouts
|
||||
|
||||
Scope: `googlehealth.activity_and_fitness.readonly`. Each exercise session → one
|
||||
`Workout` row (NOT a Measurement), keyed `(userId, source: "GOOGLE_HEALTH",
|
||||
externalId)` where externalId is the session id (or `exercise:<startISO>` when
|
||||
absent). Fields: sportType (Google activity type → canonical `WorkoutSportType`,
|
||||
`other` fallback), startedAt/endedAt, durationSec, totalEnergyKcal (active session
|
||||
energy), totalDistanceM, avg/max/min HR (optional). A Google Health run and the
|
||||
Scope: `googlehealth.activity_and_fitness.readonly`. Sessions filter **only** on
|
||||
`exercise.interval.civil_start_time` with an offset-less civil bound (derived
|
||||
from the watermark in the user's zone). Each exercise session → one `Workout`
|
||||
row (NOT a Measurement), keyed `(userId, source: "GOOGLE_HEALTH", externalId)`
|
||||
where externalId is the DataPoint's top-level `name` resource name (there is no
|
||||
session-id field; `exercise:<startISO>` when absent). Fields: sportType
|
||||
(`exercise.exerciseType` UPPERCASE enum → canonical `WorkoutSportType`, `other`
|
||||
fallback), startedAt/endedAt from `exercise.interval.startTime/endTime`
|
||||
(RFC-3339), durationSec, totalEnergyKcal
|
||||
(`exercise.metricsSummary.caloriesKcal`), totalDistanceM
|
||||
(`exercise.metricsSummary.distanceMillimeters` ÷ 1000), avgHeartRate
|
||||
(`exercise.metricsSummary.averageHeartRateBeatsPerMinute`, int64 string).
|
||||
metricsSummary carries **no max/min HR** → null. A Google Health run and the
|
||||
same run via Apple Health / WHOOP stay distinct rows; the read-time
|
||||
`pickCanonicalWorkoutRows` picker collapses the cross-source twin per the user's
|
||||
source ladder.
|
||||
@@ -133,3 +163,21 @@ rows: `externalId = <anchor>:<fieldTag>`. Daily cumulative activity rows:
|
||||
Workouts: `(userId, source: "GOOGLE_HEALTH", externalId)` on the `Workout` table.
|
||||
A re-fetch of the same window (daily summaries re-roll after the fact, so the
|
||||
incremental overlap is 24 h) overwrites in place.
|
||||
|
||||
## Error surfacing
|
||||
|
||||
Non-2xx Google Health bodies carry the AIP-193 envelope
|
||||
(`{"error":{code,message,status}}`). The client extracts a redacted
|
||||
`STATUS: message` detail (message capped at 200 chars, bearer tokens and URL
|
||||
query strings stripped) into `GoogleHealthApiError.upstreamError` and the
|
||||
external-call annotation, so a field-grammar 400 is diagnosable from operator
|
||||
logs. The OAuth token endpoint uses the flat `{"error":"invalid_grant"}` shape
|
||||
instead — handled separately in `postToken`.
|
||||
|
||||
## Structure probe
|
||||
|
||||
`POST /api/integrations/google-health/test` with body
|
||||
`{"probe":"structure"}` fetches ONE recent page/window per data type and returns
|
||||
only the JSON **structure** (field names + `typeof` leaves — never values), plus
|
||||
the per-type error verdict on failure. Self-hoster diagnostics for payload-shape
|
||||
drift.
|
||||
@@ -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