mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
This commit is contained in:
1 parent
b3fea2ef59
commit
1f4ba5939d
1000 files changed
+12130
-8385
No files matched your search
@@ -295,34 +295,40 @@ describe("fetchDataPoints", () => {
|
||||
// steps / distance / calories / floors, sleep and exercise are INTERVAL data
|
||||
// types — Google anchors them on `interval.start_time`. A sample_time filter
|
||||
// 400s/empties for these and stalls incremental sync.
|
||||
const stepsMock = installFetchMock([{ status: 200, body: { dataPoints: [] } }]);
|
||||
const stepsMock = installFetchMock([
|
||||
{ status: 200, body: { dataPoints: [] } },
|
||||
]);
|
||||
await fetchDataPoints(FITBIT_DATA_TYPES.steps, "at", "fetchSteps", {
|
||||
start: new Date("2026-03-04T10:00:00.000Z"),
|
||||
});
|
||||
expect(
|
||||
new URL((stepsMock.mock.calls[0] as unknown as [string])[0]).searchParams.get(
|
||||
"filter",
|
||||
),
|
||||
new URL(
|
||||
(stepsMock.mock.calls[0] as unknown as [string])[0],
|
||||
).searchParams.get("filter"),
|
||||
).toBe('steps.interval.start_time >= "2026-03-04T10:00:00.000Z"');
|
||||
|
||||
const sleepMock = installFetchMock([{ status: 200, body: { dataPoints: [] } }]);
|
||||
const sleepMock = installFetchMock([
|
||||
{ status: 200, body: { dataPoints: [] } },
|
||||
]);
|
||||
await fetchDataPoints(FITBIT_DATA_TYPES.sleep, "at", "fetchSleep", {
|
||||
start: new Date("2026-03-04T10:00:00.000Z"),
|
||||
});
|
||||
expect(
|
||||
new URL((sleepMock.mock.calls[0] as unknown as [string])[0]).searchParams.get(
|
||||
"filter",
|
||||
),
|
||||
new URL(
|
||||
(sleepMock.mock.calls[0] as unknown as [string])[0],
|
||||
).searchParams.get("filter"),
|
||||
).toBe('sleep.interval.start_time >= "2026-03-04T10:00:00.000Z"');
|
||||
|
||||
const exMock = installFetchMock([{ status: 200, body: { dataPoints: [] } }]);
|
||||
const exMock = installFetchMock([
|
||||
{ status: 200, body: { dataPoints: [] } },
|
||||
]);
|
||||
await fetchDataPoints(FITBIT_DATA_TYPES.exercise, "at", "fetchExercise", {
|
||||
start: new Date("2026-03-04T10:00:00.000Z"),
|
||||
});
|
||||
expect(
|
||||
new URL((exMock.mock.calls[0] as unknown as [string])[0]).searchParams.get(
|
||||
"filter",
|
||||
),
|
||||
new URL(
|
||||
(exMock.mock.calls[0] as unknown as [string])[0],
|
||||
).searchParams.get("filter"),
|
||||
).toBe('exercise.interval.start_time >= "2026-03-04T10:00:00.000Z"');
|
||||
});
|
||||
|
||||
@@ -466,7 +472,9 @@ describe("activity mappers (cumulative daily)", () => {
|
||||
});
|
||||
|
||||
it("maps distance in metres, converting km → m when reported in km", () => {
|
||||
expect(mapDistance({ distance: { meters: 6200, interval } })[0]).toMatchObject({
|
||||
expect(
|
||||
mapDistance({ distance: { meters: 6200, interval } })[0],
|
||||
).toMatchObject({
|
||||
type: "WALKING_RUNNING_DISTANCE",
|
||||
value: 6200,
|
||||
unit: "m",
|
||||
@@ -506,7 +514,9 @@ describe("activity mappers (cumulative daily)", () => {
|
||||
expect(out[0]).toMatchObject({ type: "VO2_MAX", value: 47.3 });
|
||||
// VO2 max of 0 is garbage — dropped (unlike the running totals).
|
||||
expect(
|
||||
mapVo2Max({ vo2_max: { milliliters_per_kilogram_per_minute: 0, date: day } }),
|
||||
mapVo2Max({
|
||||
vo2_max: { milliliters_per_kilogram_per_minute: 0, date: day },
|
||||
}),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -667,7 +677,9 @@ describe("workout mapping", () => {
|
||||
});
|
||||
|
||||
it("returns null for a session with no usable time span", () => {
|
||||
expect(mapWorkout({ exercise: { startTime: "2026-05-10T07:00:00.000Z" } })).toBeNull();
|
||||
expect(
|
||||
mapWorkout({ exercise: { startTime: "2026-05-10T07:00:00.000Z" } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
mapWorkout({
|
||||
exercise: {
|
||||
|
||||
@@ -72,9 +72,9 @@ describe("classifyFitbitError", () => {
|
||||
});
|
||||
|
||||
it("parses the status out of a legacy message shape", () => {
|
||||
expect(classifyFitbitError(new Error("Fitbit fetchProfile error: 401"))).toBe(
|
||||
"reauth_required",
|
||||
);
|
||||
expect(
|
||||
classifyFitbitError(new Error("Fitbit fetchProfile error: 401")),
|
||||
).toBe("reauth_required");
|
||||
expect(classifyFitbitError(new Error("Fitbit sync error: 500"))).toBe(
|
||||
"transient",
|
||||
);
|
||||
|
||||
@@ -202,9 +202,7 @@ describe("syncUserFitbit — single cycle-wide watermark", () => {
|
||||
// Every resource receives the identical resolved `start` (lastSyncedAt - 24h
|
||||
// overlap). A per-resource read+stamp would have given later resources a
|
||||
// start of ~now, dropping the gap after an outage longer than the overlap.
|
||||
const expectedStart = new Date(
|
||||
LAST_SYNCED.getTime() - 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const expectedStart = new Date(LAST_SYNCED.getTime() - 24 * 60 * 60 * 1000);
|
||||
for (const fn of [
|
||||
syncUserMetrics,
|
||||
syncUserActivity,
|
||||
|
||||
@@ -15,10 +15,9 @@ const { prismaMock, refreshAccessTokenMock, recordSyncFailure } = vi.hoisted(
|
||||
update: vi.fn(),
|
||||
},
|
||||
measurement: {
|
||||
findMany:
|
||||
vi.fn<(arg: Record<string, unknown>) => Promise<unknown[]>>(
|
||||
async () => [],
|
||||
),
|
||||
findMany: vi.fn<(arg: Record<string, unknown>) => Promise<unknown[]>>(
|
||||
async () => [],
|
||||
),
|
||||
createMany: vi.fn<
|
||||
(arg: Record<string, unknown>) => Promise<{ count: number }>
|
||||
>(async (arg) => ({
|
||||
@@ -30,16 +29,17 @@ const { prismaMock, refreshAccessTokenMock, recordSyncFailure } = vi.hoisted(
|
||||
},
|
||||
},
|
||||
refreshAccessTokenMock: vi.fn(),
|
||||
recordSyncFailure: vi.fn<(...a: unknown[]) => Promise<void>>(async () => {}),
|
||||
recordSyncFailure: vi.fn<(...a: unknown[]) => Promise<void>>(
|
||||
async () => {},
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
collapseToTypeDayKeys: (
|
||||
rows: Array<{ type: string; measuredAt: Date }>,
|
||||
) => rows,
|
||||
collapseToTypeDayKeys: (rows: Array<{ type: string; measuredAt: Date }>) =>
|
||||
rows,
|
||||
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
||||
recomputeUserRollups: vi.fn(async () => ({ rowsUpserted: 0, durationMs: 0 })),
|
||||
}));
|
||||
@@ -311,8 +311,20 @@ describe("upsertFitbitMeasurements — batched, tombstone-safe write", () => {
|
||||
]);
|
||||
|
||||
const { imported } = await upsertFitbitMeasurements("user1", [
|
||||
{ type: "WEIGHT", value: 1, unit: "kg", measuredAt, externalId: "live-key" },
|
||||
{ type: "WEIGHT", value: 2, unit: "kg", measuredAt, externalId: "fresh-key" },
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 1,
|
||||
unit: "kg",
|
||||
measuredAt,
|
||||
externalId: "live-key",
|
||||
},
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 2,
|
||||
unit: "kg",
|
||||
measuredAt,
|
||||
externalId: "fresh-key",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(imported).toBe(2);
|
||||
@@ -345,9 +357,8 @@ describe("upsertFitbitMeasurements — batched, tombstone-safe write", () => {
|
||||
});
|
||||
|
||||
it("defers the rollup hook and returns touched keys when deferRollup is set", async () => {
|
||||
const { recomputeBucketsForMeasurement } = await import(
|
||||
"@/lib/rollups/measurement-rollups"
|
||||
);
|
||||
const { recomputeBucketsForMeasurement } =
|
||||
await import("@/lib/rollups/measurement-rollups");
|
||||
const measuredAt = new Date("2026-05-10T07:00:00.000Z");
|
||||
prismaMock.measurement.findMany.mockResolvedValue([]);
|
||||
|
||||
|
||||
+61
-23
@@ -54,7 +54,8 @@ export interface FitbitCredentials {
|
||||
function getRedirectUri(): string {
|
||||
const explicit = process.env.FITBIT_REDIRECT_URI;
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
|
||||
const raw = explicit ?? (appUrl ? `${appUrl}/api/fitbit/callback` : undefined);
|
||||
const raw =
|
||||
explicit ?? (appUrl ? `${appUrl}/api/fitbit/callback` : undefined);
|
||||
|
||||
if (!raw) {
|
||||
throw new Error(
|
||||
@@ -74,7 +75,10 @@ function getRedirectUri(): string {
|
||||
parsed.hostname === "127.0.0.1" ||
|
||||
parsed.hostname === "[::1]" ||
|
||||
parsed.hostname === "::1";
|
||||
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback)) {
|
||||
if (
|
||||
parsed.protocol !== "https:" &&
|
||||
!(parsed.protocol === "http:" && isLoopback)
|
||||
) {
|
||||
throw new Error(
|
||||
`Fitbit redirect_uri must be https (or http on localhost): ${parsed.origin}`,
|
||||
);
|
||||
@@ -510,7 +514,9 @@ export async function fetchDataPoints(
|
||||
},
|
||||
);
|
||||
|
||||
const json = (await res.json().catch(() => null)) as FitbitDataPointPage | null;
|
||||
const json = (await res
|
||||
.json()
|
||||
.catch(() => null)) as FitbitDataPointPage | null;
|
||||
const verdict = classifyFitbitResponse(res.status);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "fitbit",
|
||||
@@ -591,10 +597,7 @@ export type FitbitSleepStage =
|
||||
* the small set of shapes the field is likely to take (a bare `value`, a typed
|
||||
* sub-object, a `{ value: { fpVal } }` wrapper, …); the first finite hit wins.
|
||||
*/
|
||||
function firstNumber(
|
||||
point: FitbitDataPoint,
|
||||
paths: string[],
|
||||
): number | null {
|
||||
function firstNumber(point: FitbitDataPoint, paths: string[]): number | null {
|
||||
for (const path of paths) {
|
||||
const v = readPath(point, path);
|
||||
if (positive(v)) return v;
|
||||
@@ -660,7 +663,10 @@ function resolveMeasuredAt(
|
||||
const d = new Date(t);
|
||||
if (!Number.isNaN(d.getTime())) return d;
|
||||
}
|
||||
const civil = readPath(point, `${dataType.filter}.interval.civil_start_time`);
|
||||
const civil = readPath(
|
||||
point,
|
||||
`${dataType.filter}.interval.civil_start_time`,
|
||||
);
|
||||
if (typeof civil === "string") {
|
||||
const d = new Date(civil);
|
||||
if (!Number.isNaN(d.getTime())) return d;
|
||||
@@ -685,7 +691,10 @@ function resolveMeasuredAt(
|
||||
* field-tag this makes the upsert key idempotent across re-fetches (a re-sync of
|
||||
* the same window overwrites in place rather than minting a duplicate).
|
||||
*/
|
||||
function externalAnchor(point: FitbitDataPoint, dataType: FitbitDataType): string {
|
||||
function externalAnchor(
|
||||
point: FitbitDataPoint,
|
||||
dataType: FitbitDataType,
|
||||
): string {
|
||||
const at = resolveMeasuredAt(point, dataType, new Date(0));
|
||||
// The mapper-driven interval types (steps/distance/calories/floors) are daily
|
||||
// totals, so they share the civil-day externalId grain with `date` summaries —
|
||||
@@ -705,7 +714,13 @@ function externalAnchor(point: FitbitDataPoint, dataType: FitbitDataType): strin
|
||||
function mapSimple(
|
||||
point: FitbitDataPoint,
|
||||
dataType: FitbitDataType,
|
||||
spec: { type: string; unit: string; fieldTag: string; valuePaths: string[]; factor?: number },
|
||||
spec: {
|
||||
type: string;
|
||||
unit: string;
|
||||
fieldTag: string;
|
||||
valuePaths: string[];
|
||||
factor?: number;
|
||||
},
|
||||
): FitbitMappedMeasurement[] {
|
||||
let value = firstNumber(point, spec.valuePaths);
|
||||
if (value === null) return [];
|
||||
@@ -813,7 +828,9 @@ export function mapRespiratoryRate(
|
||||
});
|
||||
}
|
||||
|
||||
export function mapHeartRate(point: FitbitDataPoint): FitbitMappedMeasurement[] {
|
||||
export function mapHeartRate(
|
||||
point: FitbitDataPoint,
|
||||
): FitbitMappedMeasurement[] {
|
||||
const dt = FITBIT_DATA_TYPES.heartRate;
|
||||
return mapSimple(point, dt, {
|
||||
type: "PULSE",
|
||||
@@ -875,7 +892,12 @@ export const FITBIT_FIELD_MAP: Record<
|
||||
filter: "weight",
|
||||
note: "picker ranks a real Withings scale above Fitbit",
|
||||
},
|
||||
bodyFat: { type: "BODY_FAT", unit: "%", path: "body-fat", filter: "body_fat" },
|
||||
bodyFat: {
|
||||
type: "BODY_FAT",
|
||||
unit: "%",
|
||||
path: "body-fat",
|
||||
filter: "body_fat",
|
||||
},
|
||||
oxygenSaturation: {
|
||||
type: "OXYGEN_SATURATION",
|
||||
unit: "%",
|
||||
@@ -1047,7 +1069,10 @@ export function mapSteps(point: FitbitDataPoint): FitbitMappedMeasurement[] {
|
||||
type: "ACTIVITY_STEPS",
|
||||
unit: "steps",
|
||||
fieldTag: "steps",
|
||||
valuePaths: [...valuePaths(dt.filter, "count"), ...valuePaths(dt.filter, "steps")],
|
||||
valuePaths: [
|
||||
...valuePaths(dt.filter, "count"),
|
||||
...valuePaths(dt.filter, "steps"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1055,10 +1080,7 @@ export function mapDistance(point: FitbitDataPoint): FitbitMappedMeasurement[] {
|
||||
const dt = FITBIT_DATA_TYPES.distance;
|
||||
// Google may report metres or kilometres; prefer the explicit-metres field,
|
||||
// else convert km → m. Both pass the non-negative guard.
|
||||
const meters = firstNonNegativeNumber(
|
||||
point,
|
||||
valuePaths(dt.filter, "meters"),
|
||||
);
|
||||
const meters = firstNonNegativeNumber(point, valuePaths(dt.filter, "meters"));
|
||||
if (meters !== null) {
|
||||
return [
|
||||
{
|
||||
@@ -1104,7 +1126,10 @@ export function mapFloors(point: FitbitDataPoint): FitbitMappedMeasurement[] {
|
||||
type: "FLIGHTS_CLIMBED",
|
||||
unit: "flights",
|
||||
fieldTag: "floors",
|
||||
valuePaths: [...valuePaths(dt.filter, "count"), ...valuePaths(dt.filter, "floors")],
|
||||
valuePaths: [
|
||||
...valuePaths(dt.filter, "count"),
|
||||
...valuePaths(dt.filter, "floors"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1152,8 +1177,15 @@ const FITBIT_SLEEP_STAGE_MAP: Record<string, FitbitSleepStage> = {
|
||||
*/
|
||||
export function mapFitbitSleepStage(raw: unknown): FitbitSleepStage | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const key = raw.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
||||
return FITBIT_SLEEP_STAGE_MAP[key] ?? FITBIT_SLEEP_STAGE_MAP[key.replace(/_/g, "")] ?? null;
|
||||
const key = raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s-]+/g, "_");
|
||||
return (
|
||||
FITBIT_SLEEP_STAGE_MAP[key] ??
|
||||
FITBIT_SLEEP_STAGE_MAP[key.replace(/_/g, "")] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** One sleep-stage segment pulled defensively off a Google sleep session. */
|
||||
@@ -1331,7 +1363,10 @@ const FITBIT_EXERCISE_TYPE_MAP: Record<string, string> = {
|
||||
/** Resolve a Google exercise activity type to a canonical sport label. */
|
||||
export function mapFitbitSportType(raw: unknown): string {
|
||||
if (typeof raw !== "string" || raw.trim() === "") return "other";
|
||||
const key = raw.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
||||
const key = raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s-]+/g, "_");
|
||||
return (
|
||||
FITBIT_EXERCISE_TYPE_MAP[key] ??
|
||||
FITBIT_EXERCISE_TYPE_MAP[key.replace(/_/g, "")] ??
|
||||
@@ -1402,7 +1437,9 @@ export function mapWorkout(point: FitbitDataPoint): FitbitMappedWorkout | null {
|
||||
]);
|
||||
if (!startedAt || !endedAt || endedAt <= startedAt) return null;
|
||||
|
||||
const durationSec = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000);
|
||||
const durationSec = Math.round(
|
||||
(endedAt.getTime() - startedAt.getTime()) / 1000,
|
||||
);
|
||||
|
||||
const sessionId =
|
||||
(typeof readPath(point, `${f}.session_id`) === "string" &&
|
||||
@@ -1453,7 +1490,8 @@ export function mapWorkout(point: FitbitDataPoint): FitbitMappedWorkout | null {
|
||||
endedAt,
|
||||
durationSec,
|
||||
totalEnergyKcal: energyKcal !== null ? Math.round(energyKcal) : null,
|
||||
totalDistanceM: distanceM !== null && distanceM >= 0 ? round2(distanceM) : null,
|
||||
totalDistanceM:
|
||||
distanceM !== null && distanceM >= 0 ? round2(distanceM) : null,
|
||||
avgHeartRate: avgHr !== null && avgHr > 0 ? Math.round(avgHr) : null,
|
||||
maxHeartRate: maxHr !== null && maxHr > 0 ? Math.round(maxHr) : null,
|
||||
minHeartRate: minHr !== null && minHr > 0 ? Math.round(minHr) : null,
|
||||
|
||||
+18
-18
@@ -37,17 +37,17 @@ both forms per type so a fetcher can never encode the wrong one.
|
||||
|
||||
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 Fitbit. |
|
||||
| `body-fat` / `body_fat` | `BODY_FAT` | % | `body_fat` | sample | |
|
||||
| `daily-oxygen-saturation` / `daily_oxygen_saturation` | `OXYGEN_SATURATION` | % | `spo2` | daily | Already 0–100. |
|
||||
| `daily-heart-rate-variability` / `daily_heart_rate_variability` | `HEART_RATE_VARIABILITY` | ms | `hrv` | daily | **Decision:** SDNN slot (Apple-comparable), NOT `HRV_RMSSD`. Fitbit reports an RMSSD-style nightly HRV — confirm the estimator at build and reconsider `HRV_RMSSD` if warranted. |
|
||||
| `daily-resting-heart-rate` / `daily_resting_heart_rate` | `RESTING_HEART_RATE` | bpm | `rhr` | daily | |
|
||||
| `daily-respiratory-rate` / `daily_respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | |
|
||||
| `heart-rate` / `heart_rate` | `PULSE` | bpm | `hr` | sample | Intraday spot HR. |
|
||||
| `daily-sleep-temperature-derivations` / `daily_sleep_temperature_derivations` | `WRIST_TEMPERATURE` | celsius | `wrist_temp` | daily | Closest semantic slot to Apple sleeping-wrist-temp. Confirm absolute-vs-baseline at build; the positive guard rejects a baseline-delta reading. |
|
||||
| `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 / filter) | MeasurementType | Unit | fieldTag | Grain | Note |
|
||||
| ----------------------------------------------------------------------------- | ------------------------ | ----------- | ------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `weight` / `weight` | `WEIGHT` | kg | `weight` | sample | Picker ranks a real Withings scale above Fitbit. |
|
||||
| `body-fat` / `body_fat` | `BODY_FAT` | % | `body_fat` | sample | |
|
||||
| `daily-oxygen-saturation` / `daily_oxygen_saturation` | `OXYGEN_SATURATION` | % | `spo2` | daily | Already 0–100. |
|
||||
| `daily-heart-rate-variability` / `daily_heart_rate_variability` | `HEART_RATE_VARIABILITY` | ms | `hrv` | daily | **Decision:** SDNN slot (Apple-comparable), NOT `HRV_RMSSD`. Fitbit reports an RMSSD-style nightly HRV — confirm the estimator at build and reconsider `HRV_RMSSD` if warranted. |
|
||||
| `daily-resting-heart-rate` / `daily_resting_heart_rate` | `RESTING_HEART_RATE` | bpm | `rhr` | daily | |
|
||||
| `daily-respiratory-rate` / `daily_respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | |
|
||||
| `heart-rate` / `heart_rate` | `PULSE` | bpm | `hr` | sample | Intraday spot HR. |
|
||||
| `daily-sleep-temperature-derivations` / `daily_sleep_temperature_derivations` | `WRIST_TEMPERATURE` | celsius | `wrist_temp` | daily | Closest semantic slot to Apple sleeping-wrist-temp. Confirm absolute-vs-baseline at build; the positive guard rejects a baseline-delta reading. |
|
||||
| `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. |
|
||||
|
||||
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).
|
||||
@@ -68,13 +68,13 @@ place rather than minting a duplicate, matching the Apple-Health
|
||||
(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).
|
||||
|
||||
| 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-calories` / `active_calories` | `ACTIVE_ENERGY_BURNED` | kcal | `active_calories` | **ACTIVE portion only** — NOT total caloriesOut (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 / 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-calories` / `active_calories` | `ACTIVE_ENERGY_BURNED` | kcal | `active_calories` | **ACTIVE portion only** — NOT total caloriesOut (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 |
|
||||
|
||||
## Sleep bundle (W5)
|
||||
|
||||
|
||||
@@ -60,7 +60,11 @@ interface MetricResource {
|
||||
/** The launch metric resources (Measurement-producing). Height is handled separately. */
|
||||
const METRIC_RESOURCES: MetricResource[] = [
|
||||
{ dataType: FITBIT_DATA_TYPES.weight, map: mapWeight, verb: "fetchWeight" },
|
||||
{ dataType: FITBIT_DATA_TYPES.bodyFat, map: mapBodyFat, verb: "fetchBodyFat" },
|
||||
{
|
||||
dataType: FITBIT_DATA_TYPES.bodyFat,
|
||||
map: mapBodyFat,
|
||||
verb: "fetchBodyFat",
|
||||
},
|
||||
{
|
||||
dataType: FITBIT_DATA_TYPES.oxygenSaturation,
|
||||
map: mapOxygenSaturation,
|
||||
|
||||
+16
-11
@@ -360,9 +360,7 @@ export async function upsertFitbitMeasurements(
|
||||
} else {
|
||||
// A duplicate fresh key inside the same batch — overwrite the planned
|
||||
// create's payload so last-write-wins, matching the prior upsert loop.
|
||||
const idx = toCreate.findIndex(
|
||||
(c) => `${c.type} | ||||