Merge branch 'fix/v14311-integ' into release/v1.43.11

This commit is contained in:
Marc-André Bombeck committed 2026-10-21 14:13:37 +02:00
commit eaaff23858
88 files changed
+3311 -384

No files matched your search

@@ -98,6 +98,7 @@ const LEGIT_IDENTICAL = new Set<string>([
"insights.derived.composite.READINESS.component.hrv",
"labs.catalog.hs-crp",
"measurements.typeHrvRmssd",
"illness.vital.HRV_RMSSD",
"settings.sections.sources.metrics.hrvRmssd",
"measurements.typeVo2Max",
// "{hours} h {minutes} min": the SI symbols for hour and minute.
@@ -56,6 +56,7 @@ import { GET } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
function connectRequest(): NextRequest {
return new NextRequest("http://localhost/api/fitbit/connect", {
@@ -112,4 +113,16 @@ describe("GET /api/fitbit/connect", () => {
expect(location).toContain("reason=nocreds");
expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled();
});
it("redirects to reason=retired once Google has turned the Web API off", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
const response = await GET(connectRequest());
const location = response.headers.get("location") ?? "";
expect(location).toContain("fitbit=error");
expect(location).toContain("reason=retired");
expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
});
+10
View File
@@ -12,6 +12,7 @@ import {
import { NextRequest, NextResponse } from "next/server";
import { relativeRedirect } from "@/lib/http/relative-redirect";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
/**
* Redirect the user to the Fitbit OAuth consent page (classic
@@ -57,6 +58,15 @@ export const GET = apiHandler(async (req: NextRequest) => {
);
}
// A grant issued now would never sync anything: Google has turned the Web
// API off. The card explains the move to Google Health.
if (isFitbitRetired()) {
annotate({ action: { name: "fitbit.connect.retired" } });
return relativeRedirect(
"/settings/integrations?fitbit=error&reason=retired",
);
}
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
annotate({ action: { name: "fitbit.connect.no_credentials" } });
@@ -0,0 +1,68 @@
/**
* POST /api/fitbit/sync once Google has turned the Fitbit Web API off: a
* clear 410 naming the cause, and no sync attempt behind it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
const { syncUserFitbitMock } = vi.hoisted(() => ({
syncUserFitbitMock: vi.fn(),
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof ApiHandlerModule>("@/lib/api-handler");
return {
...actual,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
};
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/fitbit/sync", () => ({ syncUserFitbit: syncUserFitbitMock }));
import { POST } from "../route";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
const post = POST as unknown as (r: NextRequest) => Promise<Response>;
function request(): NextRequest {
return new NextRequest("http://localhost/api/fitbit/sync", {
method: "POST",
});
}
beforeEach(() => {
vi.clearAllMocks();
syncUserFitbitMock.mockResolvedValue({ imported: 2, failed: false });
});
describe("POST /api/fitbit/sync", () => {
it("syncs while the Web API is still up", async () => {
const response = await post(request());
expect(response.status).toBe(200);
expect(syncUserFitbitMock).toHaveBeenCalledWith("u1", {
fullSync: false,
});
});
it("answers 410 provider_retired after the turndown and syncs nothing", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
const response = await post(request());
expect(response.status).toBe(410);
const body = (await response.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("provider_retired");
expect(body.error).toMatch(/Google Health/);
expect(syncUserFitbitMock).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
});
+9
View File
@@ -4,6 +4,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { readSyncTriggerBody } from "@/lib/integrations/sync-request";
import { syncUserFitbit } from "@/lib/fitbit/sync";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { NextRequest } from "next/server";
@@ -31,6 +32,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
if (isFitbitRetired()) {
return apiError(
"Google has turned the Fitbit Web API off. Connect Google Health instead.",
410,
{ errorCode: "provider_retired" },
);
}
const body = await readSyncTriggerBody(request);
if (body.error) return body.error;
const { fullSync } = body;
@@ -36,6 +36,7 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { getValidToken } from "@/lib/fitbit/sync-core";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
interface ApiErrorEnvelope {
data: null;
@@ -109,6 +110,22 @@ describe("POST /api/integrations/fitbit/test", () => {
expect(global.fetch).not.toHaveBeenCalled();
});
it("answers 410 provider_retired once Google has turned the Web API off", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(410);
const body = (await response.json()) as ApiErrorEnvelope & {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("provider_retired");
expect(getValidToken).not.toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
it("returns 422 when no Fitbit connection exists", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce(null);
const response = await POST(emptyRequest() as never);
@@ -7,6 +7,7 @@ import { prisma } from "@/lib/db";
import { getValidToken } from "@/lib/fitbit/sync-core";
import { FITBIT_API_BASE } from "@/lib/fitbit/client";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
export const dynamic = "force-dynamic";
@@ -55,6 +56,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
if (isFitbitRetired()) {
return apiError(
"Google has turned the Fitbit Web API off. Connect Google Health instead.",
410,
{ errorCode: "provider_retired" },
);
}
const tokenInfo = await getValidToken(user.id);
if (!tokenInfo) {
return apiError("Fitbit not connected", 422, {
+7
View File
@@ -57,6 +57,10 @@ import {
type GoogleHealthBackfillEndedEntry,
} from "@/lib/google-health/backfill-progress";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import {
fitbitRetirement,
type FitbitRetirement,
} from "@/lib/fitbit/retirement";
export const dynamic = "force-dynamic";
@@ -378,6 +382,7 @@ export const GET = apiHandler(async () => {
? fitbitConn.tokenExpiresAt.getTime() <= now
: null,
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
retirement: fitbitRetirement(),
health: health["fitbit"],
...resolve("fitbit", fitbitStatus, {
connected: fitbitConnected,
@@ -558,6 +563,8 @@ interface FitbitExtras {
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
backfillCompleted: boolean | null;
/** The end of the legacy Fitbit Web API (`@/lib/fitbit/retirement`). */
retirement: FitbitRetirement;
}
// v1.27.0 — Google Health mirrors the Fitbit shape plus `needsReauth`: Google
+14 -3
View File
@@ -67,13 +67,21 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never);
syncUserPolarMock.mockResolvedValue({ imported: 0, failed: false });
syncUserPolarMock.mockResolvedValue({
imported: 0,
failed: false,
tokenProven: true,
});
syncUserPolarWorkoutsMock.mockResolvedValue(0);
});
describe("POST /api/polar/sync", () => {
it("runs both Polar legs and totals what they imported", async () => {
syncUserPolarMock.mockResolvedValue({ imported: 5, failed: false });
syncUserPolarMock.mockResolvedValue({
imported: 5,
failed: false,
tokenProven: true,
});
syncUserPolarWorkoutsMock.mockResolvedValue(2);
const response = await post(request());
@@ -84,7 +92,10 @@ describe("POST /api/polar/sync", () => {
error: null,
});
expect(syncUserPolarMock).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1");
// The vitals leg tells the workouts leg the token just worked.
expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1", {
tokenProven: true,
});
});
it("does not call a run whose vitals collections all failed a success", async () => {
@@ -191,14 +191,16 @@ describe("POST /api/whoop/webhook/[token]", () => {
expect(getGlobalBoss).not.toHaveBeenCalled();
});
it("soft-deletes matching rows on a `*.deleted` event", async () => {
it("soft-deletes the recovery's own rows on a `recovery.deleted` event", async () => {
vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValue({
userId: "user-1",
} as never);
// The v2 recovery webhook id is the associated sleep's UUID, which also
// prefixes the night's sleep rows — the match names the recovery's ids.
const req = signedRequest({
user_id: 42,
id: "rec-uuid",
id: "sleep-uuid",
type: "recovery.deleted",
});
const res = await POST(req, ctx(SECRET));
@@ -208,7 +210,15 @@ describe("POST /api/whoop/webhook/[token]", () => {
where: {
userId: "user-1",
source: "WHOOP",
externalId: { startsWith: "rec-uuid:" },
externalId: {
in: [
"sleep-uuid:recovery",
"sleep-uuid:hrv_rmssd",
"sleep-uuid:rhr",
"sleep-uuid:spo2",
"sleep-uuid:skin_temp",
],
},
deletedAt: null,
},
data: { deletedAt: expect.any(Date), syncVersion: { increment: 1 } },
@@ -246,8 +246,18 @@ describe("POST /api/workouts/batch — rate limit buckets", () => {
describe("POST /api/workouts/batch — what a scoped caller writes", () => {
it("attributes its rows to EXTERNAL", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
// Two sessions of the day: EXTERNAL ranks on the workout ladder, so two
// rows of one session would land once.
const res = await POST(
makeRequest({ workouts: [workout("s-1"), workout("s-2")] }),
makeRequest({
workouts: [
workout("s-1"),
workout("s-2", {
startedAt: "2026-09-20T18:00:00.000Z",
endedAt: "2026-09-20T18:45:00.000Z",
}),
],
}),
);
expect(res.status).toBe(200);
const rows = insertedRows();
@@ -71,6 +71,15 @@ describe("oauthReasonKey", () => {
);
});
it("names the Fitbit turndown refusal with its own copy, and only for Fitbit", () => {
expect(oauthReasonKey("fitbit", "retired")).toBe(
"settings.fitbitOauthError.retired",
);
expect(oauthReasonKey("whoop", "retired")).toBe(
"settings.whoopOauthError.generic",
);
});
it("falls back to generic for an unknown tag", () => {
expect(oauthReasonKey("whoop", "totally_unknown")).toBe(
"settings.whoopOauthError.generic",
@@ -0,0 +1,47 @@
/**
* The Fitbit card's notice about the end of the legacy Web API: before the
* day it says when the connection stops, from the day on that it has, and
* both times it points at Google Health and says the data stays.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { FitbitRetirementNotice } from "../fitbit-retirement-notice";
function render(ended: boolean | null) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<FitbitRetirementNotice
retirement={
ended === null
? undefined
: { endsOn: "2026-10-30", ended, successor: "google-health" }
}
/>
</I18nProvider>,
);
}
describe("<FitbitRetirementNotice>", () => {
it("names the day the connection stops, and the way on", () => {
const html = render(false);
expect(html).toContain('data-ended="false"');
expect(html).toContain("Google turns the Fitbit Web API off on");
expect(html).toContain("Google Health reads the same Fitbit and Pixel");
expect(html).toContain("Everything already imported stays.");
expect(html).toContain('href="#google-health"');
expect(html).toContain("/google-health");
});
it("says the connection has stopped once the day has come", () => {
const html = render(true);
expect(html).toContain('data-ended="true"');
expect(html).toContain("Google turned the Fitbit Web API off on");
expect(html).not.toContain("Google turns the Fitbit Web API off on");
});
it("renders nothing without a retirement on the status entry", () => {
expect(render(null)).toBe("");
});
});
@@ -19,6 +19,7 @@ import { showErrorToast } from "@/lib/errors/error-toast";
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
import { DawarichCard } from "@/components/settings/integrations/dawarich-card";
import { FitbitRetirementNotice } from "@/components/settings/integrations/fitbit-retirement-notice";
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row";
@@ -136,6 +137,9 @@ const PROVIDER_ONLY_REASONS: Partial<
Record<OAuthOutcomeProvider, ReadonlySet<string>>
> = {
oura: new Set(["issuer"]),
// Fitbit's `retired`: Google has turned the Web API off, so the connect
// route refuses a new grant.
fitbit: new Set(["retired"]),
};
type OAuthOutcome =
@@ -216,6 +220,8 @@ export function ConnectionsPanel({
const queryClient = useQueryClient();
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
const fitbitStatus = pickStatus(integrationStatus, "fitbit");
const fitbitRetired = fitbitStatus?.retirement?.ended === true;
// OAuth callback handler — reads `?withings=connected|error&reason=…`
// from the URL (lazy initialiser, same shape as the Codex handler in
@@ -332,20 +338,35 @@ export function ConnectionsPanel({
/>
</div>
<div id="fitbit" className="scroll-mt-28">
<FitbitRetirementNotice retirement={fitbitStatus?.retirement} />
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.fitbit}
viewModel={pickStatus(integrationStatus, "fitbit")}
viewModel={fitbitStatus}
callbackUrl={callbackUrls.fitbit}
syncDisabled={fitbitRetired}
connectDisabled={fitbitRetired}
titleAccessory={
<>
<TagChip>{t("settings.fitbitTag")}</TagChip>
<Badge
variant="outline"
data-testid="fitbit-experimental-badge"
className="border-warning/50 text-warning"
>
{t("settings.fitbitExperimentalBadge")}
</Badge>
{fitbitStatus?.retirement ? (
<Badge
variant="outline"
data-testid="fitbit-retirement-badge"
className="border-warning/50 text-warning"
>
{fitbitRetired
? t("settings.fitbitRetirement.badgeEnded")
: t("settings.fitbitRetirement.badgeEnding")}
</Badge>
) : (
<Badge
variant="outline"
data-testid="fitbit-experimental-badge"
className="border-warning/50 text-warning"
>
{t("settings.fitbitExperimentalBadge")}
</Badge>
)}
</>
}
/>
@@ -0,0 +1,61 @@
"use client";
import { ArrowDown, ExternalLink } from "lucide-react";
import { InlineAction } from "@/components/ui/inline-action";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { FitbitRetirement } from "@/lib/fitbit/retirement";
import { integrationDocsHref } from "./setup-guide-link";
/**
* The end of the legacy Fitbit Web API, on the Fitbit card. Before the day it
* says when the connection stops; from the day on, that it has stopped. Both
* name the way on: Google Health reads the same devices, needs one more
* consent, and leaves everything already imported in place. The server owns
* the date and whether it has passed (`retirement` on the status envelope).
*/
export function FitbitRetirementNotice({
retirement,
}: {
retirement: FitbitRetirement | undefined;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!retirement) return null;
const date = fmt.dateShortSmartCalendar(retirement.endsOn);
return (
<div
role="note"
data-testid="fitbit-retirement-notice"
data-ended={retirement.ended ? "true" : "false"}
className="border-warning/40 bg-warning/5 text-foreground mb-3 space-y-2 rounded-md border px-3 py-2 text-sm"
>
<p>
{retirement.ended
? t("settings.fitbitRetirement.ended", { date })
: t("settings.fitbitRetirement.ending", { date })}
</p>
<p>{t("settings.fitbitRetirement.move")}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1">
<InlineAction asChild size="sm">
<a href="#google-health" data-testid="fitbit-retirement-to-google">
{t("settings.fitbitRetirement.toCard")}
<ArrowDown className="size-3" aria-hidden="true" />
</a>
</InlineAction>
<InlineAction asChild size="sm">
<a
href={integrationDocsHref("google-health")}
target="_blank"
rel="noopener noreferrer"
data-testid="fitbit-retirement-guide"
>
{t("settings.fitbitRetirement.guide")}
<ExternalLink className="size-3" aria-hidden="true" />
</a>
</InlineAction>
</div>
</div>
);
}
@@ -220,6 +220,10 @@ export function OAuthIntegrationCard(props: {
callbackUrl: IntegrationCallbackUrls[OAuthIntegrationKey];
icon?: LucideIcon;
titleAccessory?: ReactNode;
/** The provider cannot sync any more (Fitbit after its API turndown). */
syncDisabled?: boolean;
/** …and cannot be connected either. */
connectDisabled?: boolean;
}) {
const { config } = props;
const sync = useStandardSync({
@@ -229,7 +233,13 @@ export function OAuthIntegrationCard(props: {
fullSyncResult: config.fullSyncResult,
statusQueryKey: config.statusQueryKey(),
});
return <OAuthIntegrationCardView {...props} sync={sync} />;
return (
<OAuthIntegrationCardView
{...props}
sync={sync}
fullSyncDisabled={props.syncDisabled}
/>
);
}
/**
@@ -248,6 +258,7 @@ export function OAuthIntegrationCardView({
fullSyncDisabled,
syncResults,
details,
connectDisabled,
}: {
config: OAuthIntegrationConfig;
viewModel: IntegrationStatusViewModel | undefined;
@@ -263,6 +274,8 @@ export function OAuthIntegrationCardView({
syncResults?: ReactNode;
/** Provider extras for the details section. */
details?: ReactNode;
/** Greys out "Connect" whatever the credentials say. */
connectDisabled?: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -374,7 +387,7 @@ export function OAuthIntegrationCardView({
type="button"
size="sm"
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
disabled={!configured}
disabled={!configured || connectDisabled}
onClick={connect}
data-slot="integration-primary-action"
data-action="connect"
@@ -14,6 +14,7 @@ import type { GoogleHealthBackfillEndedEntry } from "@/lib/google-health/backfil
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
import type { FitbitRetirement } from "@/lib/fitbit/retirement";
export type IntegrationKey =
| "withings"
@@ -71,6 +72,8 @@ export interface IntegrationStatusViewModel {
hasToken?: boolean;
/** Per-metric last-seen timestamps with the server-computed stale flag. */
metricFreshness?: MetricFreshnessEntry[];
/** Fitbit: the end of the legacy Web API, resolved on the server. */
retirement?: FitbitRetirement;
}
export interface IntegrationStatusEnvelope {
@@ -519,25 +519,23 @@ describe("pickCanonicalSourceRows — v1.4.25 W8c two-axis device-type picker",
/**
* v1.38.x — what the EXTERNAL source does to a contested day.
*
* `EXTERNAL` is on no priority ladder, deliberately: the `steps` and `spo2`
* defaults already sit at `metricSourceLadder`'s `.max(8)`, so a ninth entry
* would come back from `GET /api/auth/me/source-priority`, be PUT back whole
* by the settings page, and 422 — breaking ladder saves for everyone.
* `EXTERNAL` was on no priority ladder at first, because the ladders were
* capped at eight entries and a ninth would have 422'd every settings save.
* The cap is the number of sources now, and since v1.43.11 `EXTERNAL` sits
* right after `MANUAL` on the ladders that rank `MANUAL`.
*
* That omission is not inert. It decides a real behaviour, and one that
* CHANGED when ingested rows stopped being labelled `MANUAL`: before, a typed
* reading and a bridged one were the same source and both survived a day; now
* `MANUAL` ranks, `EXTERNAL` does not, and the bridged row leaves the
* canonical set for that day. Seven call sites consume this picker (daily
* totals, the health score, the doctor report, correlations, mood crosstabs,
* PR detection), so the effect is user-visible and is written up in the
* changelog.
* The behaviour this pins CHANGED when ingested rows stopped being labelled
* `MANUAL`: before, a typed reading and a bridged one were the same source
* and both survived a day; now `MANUAL` outranks `EXTERNAL`, and the bridged
* row leaves the canonical set for that day. Seven call sites consume this
* picker (daily totals, the health score, the doctor report, correlations,
* mood crosstabs, PR detection), so the effect is user-visible.
*
* Pinned here because it is emergent from a rank ladder rather than stated
* anywhere in code: put `EXTERNAL` on a ladder and the first case below goes
* red, which is the review anybody adding it deserves.
* anywhere in code: rank `EXTERNAL` above `MANUAL` and the first case below
* goes red, which is the review anybody doing that deserves.
*/
describe("pickCanonicalSourceRows — the unranked EXTERNAL source", () => {
describe("pickCanonicalSourceRows — the EXTERNAL source", () => {
const day = "2026-09-02";
function weightRow(
@@ -573,10 +571,9 @@ describe("pickCanonicalSourceRows — the unranked EXTERNAL source", () => {
expect(out.pickedByDay.get(day)).toBe("MANUAL");
});
it("keeps a bridged-only day, since nothing on the ladder is present", () => {
// The `!picked` pass-through. Without this arm an account that ONLY ever
// pushes readings would go dark on every surface listed above, which
// would be a far worse regression than the one above is a change.
it("keeps a bridged-only day, picked from the ladder", () => {
// An account that ONLY ever pushes readings keeps every one of them:
// the bridge is the day's one source, and the ladder now names it.
const out = pickCanonicalSourceRows(
[
weightRow("EXTERNAL", 82.4, "06:30"),
@@ -588,8 +585,7 @@ describe("pickCanonicalSourceRows — the unranked EXTERNAL source", () => {
);
expect(out.canonicalRows).toHaveLength(2);
expect(out.canonicalRows.every((r) => r.source === "EXTERNAL")).toBe(true);
// No ladder source won, so the day names no pick.
expect(out.pickedByDay.has(day)).toBe(false);
expect(out.pickedByDay.get(day)).toBe("EXTERNAL");
});
it("kept both when the bridged row was still labelled MANUAL", () => {
@@ -607,10 +603,9 @@ describe("pickCanonicalSourceRows — the unranked EXTERNAL source", () => {
expect(out.canonicalRows).toHaveLength(2);
});
it("lets a user who ranks EXTERNAL themselves have it win", () => {
// Not reachable from the settings UI today (the ladder cap keeps it off
// the page), but the picker resolves whatever ladder it is handed, and a
// stored ladder naming it must be honoured rather than ignored.
it("lets a user who ranks EXTERNAL above MANUAL have it win", () => {
// The picker resolves whatever ladder it is handed; a stored ladder that
// puts the bridge first is honoured.
const out = pickCanonicalSourceRows(
[
weightRow("EXTERNAL", 82.4, "06:30"),
+2 -2
View File
@@ -332,13 +332,13 @@ describe("metric mappers (classic shapes)", () => {
expect(out[0]!.fieldTag).toBe("2026-05-10:spo2");
});
it("maps HRV dailyRmssd into the canonical HEART_RATE_VARIABILITY slot", () => {
it("maps HRV dailyRmssd into HRV_RMSSD, apart from the SDNN series", () => {
const out = mapHeartRateVariability({
hrv: [
{ dateTime: "2026-05-10", value: { dailyRmssd: 42.7, deepRmssd: 50 } },
],
});
expect(out[0]!.type).toBe("HEART_RATE_VARIABILITY");
expect(out[0]!.type).toBe("HRV_RMSSD");
expect(out[0]).toMatchObject({ value: 42.7, unit: "ms" });
expect(out[0]!.fieldTag).toBe("2026-05-10:hrv");
});
@@ -0,0 +1,37 @@
/**
* The turndown date of the legacy Fitbit Web API, read off the real module
* (the unit setup pins it off for every other suite).
*/
import { describe, expect, it, vi } from "vitest";
const actual = await vi.importActual<typeof import("@/lib/fitbit/retirement")>(
"@/lib/fitbit/retirement",
);
describe("Fitbit Web API retirement", () => {
it("names the day Google published", () => {
// developers.google.com/health/migration: "On October 30, 2026, the
// Fitbit Web API will be turned off".
expect(actual.FITBIT_WEB_API_TURNDOWN_DATE).toBe("2026-10-30");
});
it("turns at midnight UTC at the start of that day", () => {
expect(actual.isFitbitRetired(new Date("2026-10-29T23:59:59.999Z"))).toBe(
false,
);
expect(actual.isFitbitRetired(new Date("2026-10-30T00:00:00.000Z"))).toBe(
true,
);
});
it("publishes the day, whether it has come, and the successor", () => {
expect(actual.fitbitRetirement(new Date("2026-10-21T12:00:00Z"))).toEqual({
endsOn: "2026-10-30",
ended: false,
successor: "google-health",
});
expect(
actual.fitbitRetirement(new Date("2026-11-02T12:00:00Z")).ended,
).toBe(true);
});
});
@@ -91,10 +91,12 @@ vi.mock("../sync-workout", () => ({
}));
import {
getValidToken,
handleCollectionFetchError,
noteHardFailure,
upsertFitbitMeasurements,
} from "../sync-core";
import { isFitbitRetired } from "../retirement";
import { syncUserFitbit } from "../sync";
import { FitbitApiError } from "../response-classifier";
import { prisma } from "@/lib/db";
@@ -453,3 +455,33 @@ describe("syncUserFitbit — backfill collapses rollup recompute to one pass", (
expect(update).not.toHaveBeenCalled();
});
});
describe("after Google turned the Fitbit Web API off", () => {
it("syncs nothing and writes neither a success nor a failure", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
await expect(syncUserFitbit("u1")).resolves.toEqual({
imported: 0,
failed: false,
});
expect(syncUserMetrics).not.toHaveBeenCalled();
expect(syncUserSleep).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
expect(recordSyncFailure).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
it("hands out no token and attempts no refresh", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
await expect(getValidToken("u1")).resolves.toBeNull();
expect(findUnique).not.toHaveBeenCalled();
expect(recordSyncFailure).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
});
+8 -7
View File
@@ -741,15 +741,16 @@ export function mapHeartRateVariability(
body: unknown,
tz?: string,
): FitbitMappedMeasurement[] {
// Fitbit's HRV summary is an RMSSD estimator (`value.dailyRmssd`). It lands in
// the canonical `HEART_RATE_VARIABILITY` slot that FITBIT occupies in the
// source-priority `hrv` ladder (alongside Apple / Oura), keeping cross-source
// comparison on one axis. (`HRV_RMSSD` is reserved for the WHOOP-native slot.)
// Fitbit's HRV summary is an RMSSD estimator (`value.dailyRmssd`), so it lands
// in `HRV_RMSSD` beside WHOOP / Oura / Polar. `HEART_RATE_VARIABILITY` is the
// SDNN series; the two estimators are not interchangeable, and a Fitbit night
// ranked into the SDNN ladder used to splice RMSSD values into an Apple
// Watch SDNN series day by day.
const rows = readArray(body, "hrv") as FitbitDailyRow[];
return mapDailySummary(
rows,
{
type: "HEART_RATE_VARIABILITY",
type: "HRV_RMSSD",
unit: "ms",
fieldTag: "hrv",
leaves: ["dailyRmssd"],
@@ -987,9 +988,9 @@ export const FITBIT_FIELD_MAP: Record<
note: "spo2 summary value.avg",
},
heartRateVariability: {
type: "HEART_RATE_VARIABILITY",
type: "HRV_RMSSD",
unit: "ms",
note: "hrv summary value.dailyRmssd (RMSSD estimator); canonical HRV slot",
note: "hrv summary value.dailyRmssd (RMSSD estimator), apart from SDNN",
},
restingHeartRate: {
type: "RESTING_HEART_RATE",
+8 -8
View File
@@ -26,14 +26,14 @@ metric (kg / km); the US default is imperial.
Scope: `weight`, `oxygen_saturation`, `heartrate`, `respiratory_rate`. Verified
against the dev.fitbit.com reference (response shapes quoted there).
| Endpoint | MeasurementType | Unit | Source field | Grain | Note |
| ---------------------------------------------- | ------------------------ | ----------- | ------------------------------------------- | ----- | ----------------------------------------------------------- |
| `/1/user/-/body/log/weight/date/{s}/{e}.json` | `WEIGHT` | kg | `weight[].weight` | log | externalId anchors on `logId`. Picker ranks Withings above. |
| `/1/user/-/body/log/fat/date/{s}/{e}.json` | `BODY_FAT` | % | `fat[].fat` | log | externalId anchors on `logId`. |
| `/1/user/-/spo2/date/{s}/{e}.json` | `OXYGEN_SATURATION` | % | `[].value.avg` (bare array) | daily | day-keyed externalId. |
| `/1/user/-/hrv/date/{s}/{e}.json` | `HEART_RATE_VARIABILITY` | ms | `hrv[].value.dailyRmssd` | daily | RMSSD estimator → canonical HRV slot (not `HRV_RMSSD`). |
| `/1/user/-/activities/heart/date/{s}/{e}.json` | `RESTING_HEART_RATE` | bpm | `activities-heart[].value.restingHeartRate` | daily | rows with no resting HR for the day are skipped. |
| `/1/user/-/br/date/{s}/{e}.json` | `RESPIRATORY_RATE` | breaths/min | `br[].value.breathingRate` | daily | |
| Endpoint | MeasurementType | Unit | Source field | Grain | Note |
| ---------------------------------------------- | -------------------- | ----------- | ------------------------------------------- | ----- | ------------------------------------------------------------------- |
| `/1/user/-/body/log/weight/date/{s}/{e}.json` | `WEIGHT` | kg | `weight[].weight` | log | externalId anchors on `logId`. Picker ranks Withings above. |
| `/1/user/-/body/log/fat/date/{s}/{e}.json` | `BODY_FAT` | % | `fat[].fat` | log | externalId anchors on `logId`. |
| `/1/user/-/spo2/date/{s}/{e}.json` | `OXYGEN_SATURATION` | % | `[].value.avg` (bare array) | daily | day-keyed externalId. |
| `/1/user/-/hrv/date/{s}/{e}.json` | `HRV_RMSSD` | ms | `hrv[].value.dailyRmssd` | daily | RMSSD estimator, kept apart from the SDNN `HEART_RATE_VARIABILITY`. |
| `/1/user/-/activities/heart/date/{s}/{e}.json` | `RESTING_HEART_RATE` | bpm | `activities-heart[].value.restingHeartRate` | daily | rows with no resting HR for the day are skipped. |
| `/1/user/-/br/date/{s}/{e}.json` | `RESPIRATORY_RATE` | breaths/min | `br[].value.breathingRate` | daily | |
Each daily-summary value passes a finite + strictly-positive guard (a 0/NaN is a
garbage/empty reading and is dropped). Each endpoint caps its own date range
+51
View File
@@ -0,0 +1,51 @@
/**
* The end of the legacy Fitbit Web API.
*
* Google turns it off on 30 October 2026: "On October 30, 2026, the Fitbit
* Web API will be turned off and will no longer function or sync data to or
* from Fitbit users" (https://developers.google.com/health/migration and the
* August 2026 Google Health developer newsletter; support already ended on
* 30 September 2026). Its successor is the Google Health API, which reads the
* same Fitbit and Pixel devices. Access and refresh tokens cannot be carried
* over, so a person moves by connecting Google Health and consenting once more.
*
* From the turndown on, HealthLog makes no Web API call for any Fitbit
* connection: no poll, no backfill, no sleep repair, no token refresh, and no
* new connection. The connection row and everything it imported stay. The
* connection reads as not connected to the health read and the watchdog, so a
* silence that is the provider's own shutdown pages nobody and asks nobody to
* reconnect; the card keeps explaining the move instead.
*
* The cut is midnight UTC at the start of the published day, still the
* evening before in the Americas. Stopping those few hours early costs
* nothing Google Health does not bring back with its own history.
*/
/** The day Google turns the Web API off, as a calendar date. */
export const FITBIT_WEB_API_TURNDOWN_DATE = "2026-10-30";
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: one instant for every instance, the start of the published day (see the header)
const TURNDOWN_AT_MS = Date.parse(`${FITBIT_WEB_API_TURNDOWN_DATE}T00:00:00Z`);
/** True from the turndown on. */
export function isFitbitRetired(now: Date = new Date()): boolean {
return now.getTime() >= TURNDOWN_AT_MS;
}
/** What the status envelope publishes on the Fitbit entry. */
export interface FitbitRetirement {
/** The turndown day, `YYYY-MM-DD`. */
endsOn: string;
/** The day has come: the connection no longer syncs. */
ended: boolean;
/** The integration that reads the same devices. */
successor: "google-health";
}
export function fitbitRetirement(now: Date = new Date()): FitbitRetirement {
return {
endsOn: FITBIT_WEB_API_TURNDOWN_DATE,
ended: isFitbitRetired(now),
successor: "google-health",
};
}
+6
View File
@@ -21,6 +21,7 @@ import {
recomputeBucketsForKeys,
} from "@/lib/rollups/measurement-rollups";
import { refreshAccessToken } from "./client";
import { isFitbitRetired } from "./retirement";
import { getUserFitbitCredentials } from "./credentials";
import { FitbitApiError, classifyFitbitError } from "./response-classifier";
import type { FitbitClassification } from "./response-classifier";
@@ -71,10 +72,15 @@ export interface FitbitTokenInfo {
* response ever omits the new refresh token (a malformed reply), keep the stored
* one rather than wiping it — a blank refresh token would brick every future
* refresh.
*
* Once Google has turned the Web API off (`./retirement`) there is no token
* worth holding: null, with no refresh attempt and nothing on the ledger, so
* every caller reads the connection as having nothing to fetch.
*/
export async function getValidToken(
userId: string,
): Promise<FitbitTokenInfo | null> {
if (isFitbitRetired()) return null;
const connection = await prisma.fitbitConnection.findUnique({
where: { userId },
});
+1 -1
View File
@@ -7,7 +7,7 @@
* - body/log/weight → WEIGHT (kg)
* - body/log/fat → BODY_FAT (%)
* - spo2 summary → OXYGEN_SATURATION (%)
* - hrv summary → HEART_RATE_VARIABILITY (ms, dailyRmssd)
* - hrv summary → HRV_RMSSD (ms, dailyRmssd)
* - activities/heart → RESTING_HEART_RATE (bpm)
* - br summary → RESPIRATORY_RATE (breaths/min)
*
+8
View File
@@ -23,6 +23,7 @@ import type {
import { syncUserMetrics } from "./sync-metrics";
import { syncUserSleep } from "./sync-sleep";
import { syncUserWorkout } from "./sync-workout";
import { isFitbitRetired } from "./retirement";
/**
* Full per-user sync across every Fitbit resource. Drives the hourly poll
@@ -49,6 +50,13 @@ export async function syncUserFitbit(
userId: string,
opts: { fullSync?: boolean } = {},
): Promise<{ imported: number; failed: boolean }> {
// The Web API is gone (`./retirement`): nothing to fetch, nothing to stamp.
// Neither a success (it would paint the connection green) nor a failure (it
// would ask for a reconnect that cannot help).
if (isFitbitRetired()) {
annotate({ action: { name: "fitbit.sync.retired" } });
return { imported: 0, failed: false };
}
if (await isReauthRequired(userId, "fitbit")) {
getEvent()?.addWarning(
`fitbit sync skipped for ${userId}: parked at error_reauth`,
@@ -148,7 +148,7 @@ describe("mapOxygenSaturation — daily-oxygen-saturation summary", () => {
});
describe("mapHeartRateVariability — daily-heart-rate-variability summary", () => {
it("maps averageHeartRateVariabilityMilliseconds to the HRV slot in ms", () => {
it("maps the RMSSD daily average to HRV_RMSSD in ms", () => {
const rows = mapHeartRateVariability({
dailyHeartRateVariability: {
averageHeartRateVariabilityMilliseconds: 42.5,
@@ -157,7 +157,7 @@ describe("mapHeartRateVariability — daily-heart-rate-variability summary", ()
});
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
type: "HEART_RATE_VARIABILITY",
type: "HRV_RMSSD",
unit: "ms",
value: 42.5,
fieldTag: "2026-06-01:hrv",
+6 -6
View File
@@ -511,16 +511,16 @@ export function mapHeartRateVariability(
tz?: string,
): GoogleHealthMappedMeasurement[] {
const dt = GOOGLE_HEALTH_DATA_TYPES.heartRateVariability;
// The daily field is an unlabelled "average HRV ms". Per the design decision
// it lands in the SDNN-lineage `HEART_RATE_VARIABILITY` slot
// (Apple-comparable), NOT WHOOP's `HRV_RMSSD` (reserved for the WHOOP-native
// estimator). The per-sample type carries explicit RMSSD + SDNN fields —
// re-confirm the estimator against live data and revisit if warranted.
// Google documents the daily average as RMSSD ("average heart rate
// variability calculated using the root mean square of successive
// differences"), the same estimator the classic Fitbit API reports as
// `dailyRmssd`. It lands in `HRV_RMSSD`, apart from the SDNN
// `HEART_RATE_VARIABILITY` series.
return mapSimple(
point,
dt,
{
type: "HEART_RATE_VARIABILITY",
type: "HRV_RMSSD",
unit: "ms",
fieldTag: "hrv",
valuePaths: [`${dt.key}.averageHeartRateVariabilityMilliseconds`],
+13 -13
View File
@@ -81,19 +81,19 @@ The exercise civil bound is derived from the watermark in the USER'S zone.
Scope: `googlehealth.health_metrics_and_measurements.readonly`.
| 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.breathsPerMinute` (number) | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | `respiratory-rate` does NOT exist in the catalogue. Schema is `{date, breathsPerMinute}` — the earlier `dailyRespiratoryRateBpm` leaf never existed. |
| `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. |
| `blood-glucose` | `bloodGlucose.bloodGlucoseMilligramsPerDeciliter` (number) | `BLOOD_GLUCOSE` | mg/dL | `glucose` | sample | Already in the canonical mg/dL storage unit. Meal/timing/specimen enums not stored. |
| `core-body-temperature` | `coreBodyTemperature.temperatureCelsius` (number) | `BODY_TEMPERATURE` | celsius | `core_temp` | sample | Core slot — distinct from SKIN_TEMPERATURE / WRIST_TEMPERATURE. |
| `daily-sleep-temperature-derivations` | `dailySleepTemperatureDerivations.nightlyTemperatureCelsius` (number) | `WRIST_TEMPERATURE` | celsius | `wrist_temp` | daily | ABSOLUTE nightly skin temperature (mean over the night's samples) — the schema refutes the earlier "deviation-only" premise. Baseline/stddev derivations not stored. |
| `height` | `height.heightMillimeters` (int64 string) ÷ 10 | `User.heightCm` | cm | — | sample | Profile seed — written ONLY when null; never a Measurement. List order is DESCENDING → the seed picks max(sampleTime) explicitly. The earlier `heightMeters` leaf never existed. |
| 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` | `HRV_RMSSD` | ms | `hrv` | daily | Google documents the daily average as RMSSD, so it lands in `HRV_RMSSD`, apart from the SDNN `HEART_RATE_VARIABILITY`. |
| `daily-resting-heart-rate` | `dailyRestingHeartRate.beatsPerMinute` (int64 string) | `RESTING_HEART_RATE` | bpm | `rhr` | daily | |
| `daily-respiratory-rate` | `dailyRespiratoryRate.breathsPerMinute` (number) | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | `respiratory-rate` does NOT exist in the catalogue. Schema is `{date, breathsPerMinute}` — the earlier `dailyRespiratoryRateBpm` leaf never existed. |
| `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. |
| `blood-glucose` | `bloodGlucose.bloodGlucoseMilligramsPerDeciliter` (number) | `BLOOD_GLUCOSE` | mg/dL | `glucose` | sample | Already in the canonical mg/dL storage unit. Meal/timing/specimen enums not stored. |
| `core-body-temperature` | `coreBodyTemperature.temperatureCelsius` (number) | `BODY_TEMPERATURE` | celsius | `core_temp` | sample | Core slot — distinct from SKIN_TEMPERATURE / WRIST_TEMPERATURE. |
| `daily-sleep-temperature-derivations` | `dailySleepTemperatureDerivations.nightlyTemperatureCelsius` (number) | `WRIST_TEMPERATURE` | celsius | `wrist_temp` | daily | ABSOLUTE nightly skin temperature (mean over the night's samples) — the schema refutes the earlier "deviation-only" premise. Baseline/stddev derivations not stored. |
| `height` | `height.heightMillimeters` (int64 string) ÷ 10 | `User.heightCm` | cm | — | sample | Profile seed — written ONLY when null; never a Measurement. List order is DESCENDING → the seed picks max(sampleTime) explicitly. The earlier `heightMeters` leaf never existed. |
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
+2 -2
View File
@@ -8,7 +8,7 @@
* - weight → WEIGHT
* - body-fat → BODY_FAT
* - daily-oxygen-saturation → OXYGEN_SATURATION
* - daily-heart-rate-variability → HEART_RATE_VARIABILITY (SDNN slot; see mapper)
* - daily-heart-rate-variability → HRV_RMSSD (RMSSD; see mapper)
* - daily-resting-heart-rate → RESTING_HEART_RATE
* - daily-respiratory-rate → RESPIRATORY_RATE
* - heart-rate (intraday) → PULSE
@@ -117,7 +117,7 @@ const METRIC_RESOURCES: MetricResource[] = [
dataType: GOOGLE_HEALTH_DATA_TYPES.heartRateVariability,
map: mapHeartRateVariability,
verb: "fetchHrv",
type: "HEART_RATE_VARIABILITY",
type: "HRV_RMSSD",
},
{
dataType: GOOGLE_HEALTH_DATA_TYPES.restingHeartRate,
+2 -3
View File
@@ -40,9 +40,8 @@ export const MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS: Record<
MCP: "settings.sections.sources.sourceLabels.MCP",
GOOGLE_HEALTH: "settings.sections.sources.sourceLabels.GOOGLE_HEALTH",
STRAVA: "settings.sections.sources.sourceLabels.STRAVA",
// v1.38.x — readings pushed in under a `measurements:write` token.
// Never offered in the ladder (it is on no default priority list), but
// the map is keyed by the enum, so the entry is not optional.
// v1.38.x — readings pushed in under a `measurements:write` token. On the
// default ladders right after MANUAL since v1.43.11.
EXTERNAL: "settings.sections.sources.sourceLabels.EXTERNAL",
HEALTH_CONNECT: "settings.sections.sources.sourceLabels.HEALTH_CONNECT",
};
@@ -17,6 +17,7 @@ import {
computeIllnessCorrelation,
dayDiff,
FUNCTIONAL_IMPACT_RETURN_KEY,
ILLNESS_SCAN_TYPES,
isAdverseDeviation,
MIN_BASELINE_DAYS,
MIN_EPISODE_COVERAGE_DAYS,
@@ -803,6 +804,12 @@ describe("isAdverseDeviation", () => {
expect(isAdverseDeviation("HEART_RATE_VARIABILITY", "above")).toBe(false);
});
it("reads a drop in the nightly RMSSD series as adverse, like SDNN", () => {
expect(ILLNESS_SCAN_TYPES).toContain("HRV_RMSSD");
expect(isAdverseDeviation("HRV_RMSSD", "below")).toBe(true);
expect(isAdverseDeviation("HRV_RMSSD", "above")).toBe(false);
});
it("treats neutral-direction vitals (WEIGHT) as never adverse", () => {
expect(isAdverseDeviation("WEIGHT", "above")).toBe(false);
expect(isAdverseDeviation("WEIGHT", "below")).toBe(false);
+4
View File
@@ -118,6 +118,7 @@ export const ADVERSE_DIRECTION: Partial<
BLOOD_PRESSURE_SYS: "up",
BLOOD_PRESSURE_DIA: "up",
HEART_RATE_VARIABILITY: "down",
HRV_RMSSD: "down",
RECOVERY_SCORE: "down",
OXYGEN_SATURATION: "down",
};
@@ -130,6 +131,9 @@ export const ADVERSE_DIRECTION: Partial<
export const ILLNESS_SCAN_TYPES: MeasurementType[] = [
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
// RMSSD, scanned on its own baseline: a nightly strap or ring reading drops
// during illness the same way SDNN does.
"HRV_RMSSD",
"RECOVERY_SCORE",
"OXYGEN_SATURATION",
"BODY_TEMPERATURE",
@@ -127,6 +127,21 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => {
expect(keys).toContain("CUSTOM_METRIC:abc");
});
it("scans the nightly RMSSD series as an outcome owned by recovery", async () => {
const on = await assembleDiscoveryMatrix("u1", OPTS);
expect(
on.series.find((s) => s.key === "HRV_RMSSD" && s.role === "outcome"),
).toBeDefined();
const off = await assembleDiscoveryMatrix("u1", {
...OPTS,
modules: { recovery: false },
});
const keys = keysOf(off.series);
expect(keys).not.toContain("HRV_RMSSD");
// SDNN is a plain vital and stays.
expect(keys).toContain("HEART_RATE_VARIABILITY");
});
it("does not read the weather at all with the environment module off", async () => {
const read = vi.mocked(fetchEnvironmentSeries);
read.mockClear();
@@ -662,6 +662,12 @@ export const DISCOVERY_BEHAVIOURS = [
export const DISCOVERY_OUTCOMES = [
"SLEEP_DURATION",
"HEART_RATE_VARIABILITY",
// The nightly RMSSD series (WHOOP, Oura, Polar, Fitbit, Google Health) as
// its own outcome. It is not merged into the SDNN channel: the estimators
// differ in scale, so one series of both would correlate the switch between
// devices rather than the body. Neither HRV channel is a behaviour, so the
// two never pair with each other.
"HRV_RMSSD",
"RESTING_HEART_RATE",
"WEIGHT",
// v1.22 — blood pressure as an OUTCOME (it was behaviour-only). This makes
@@ -46,6 +46,7 @@ import { readOuraScopeDenied } from "@/lib/oura/credentials";
import { ouraScopeReconnectList } from "@/lib/oura/scope";
import { localDayWindow } from "@/lib/tz/local-day";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
export type ConnectionHealthMap = Record<
ConnectionKey,
@@ -234,7 +235,10 @@ export async function loadConnectionFacts(
const connected: Record<ConnectionKey, boolean> = {
withings: !!withingsConn,
whoop: !!whoopConn?.whoopUserId,
fitbit: !!fitbitConn,
// Once Google has turned the Fitbit Web API off the row stays (it carries
// the imported history) but nothing will sync through it: no health to
// judge and nothing for the watchdog to call silent.
fitbit: !!fitbitConn && !isFitbitRetired(now),
"google-health": !!googleHealthConn,
polar: !!dbUser?.polarAccessTokenEncrypted,
oura: !!dbUser?.ouraAccessTokenEncrypted,
+11 -1
View File
@@ -49,6 +49,13 @@ import { annotate } from "@/lib/logging/context";
export interface WalkPage<C, T> {
items: readonly T[];
next: C | null | undefined;
/**
* The provider refused to go on for now (a rate limit) with history left.
* The walk ends here with what it read, reported `truncated` like a walk
* cut off at its ceiling, so a backfill resumes later instead of stamping
* a history it did not finish.
*/
interrupted?: boolean;
}
/** What the walk came to. */
@@ -78,6 +85,9 @@ export async function walkPages<C, T>(
for (let page = 0; page < maxPages; page += 1) {
const result = await fetchPage(cursor, page);
items.push(...result.items);
if (result.interrupted) {
return truncated(label, items, page + 1, "interrupted");
}
const next = result.next;
if (next === null || next === undefined || next === "") {
return { items, pages: page + 1, truncated: false };
@@ -93,7 +103,7 @@ function truncated<T>(
label: string,
items: T[],
pages: number,
reason: "ceiling" | "repeat",
reason: "ceiling" | "repeat" | "interrupted",
): PageWalk<T> {
annotate({
action: { name: "integration.page_walk.truncated" },
@@ -43,6 +43,7 @@ import {
enqueueBootTimeFitbitBackfill,
runFitbitBackfillForUser,
} from "../fitbit-backfill";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
beforeEach(() => {
vi.clearAllMocks();
@@ -146,3 +147,28 @@ describe("runFitbitBackfillForUser — a walk that reached its ceiling", () => {
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
});
});
describe("after Google turned the Fitbit Web API off", () => {
it("enqueues no backfill and runs a queued one without walking or stamping", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
prismaMock.fitbitConnection.findMany.mockResolvedValue([
{ userId: "u1" },
]);
await expect(enqueueBootTimeFitbitBackfill()).resolves.toEqual({
enqueued: 0,
skipped: 0,
error: null,
});
expect(bossSend).not.toHaveBeenCalled();
await expect(runFitbitBackfillForUser("u1")).resolves.toEqual({
imported: 0,
});
expect(syncUserFitbit).not.toHaveBeenCalled();
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
});
@@ -71,6 +71,7 @@ import {
enqueueBootTimeFitbitSleepRepair,
runFitbitSleepRepairForUser,
} from "../fitbit-sleep-repair";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
beforeEach(() => {
vi.clearAllMocks();
@@ -231,3 +232,29 @@ describe("runFitbitSleepRepairForUser", () => {
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
});
});
describe("after Google turned the Fitbit Web API off", () => {
it("enqueues no repair and runs a queued one without reading or stamping", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
prismaMock.fitbitConnection.findMany.mockResolvedValue([
{ userId: "u1" },
]);
await expect(enqueueBootTimeFitbitSleepRepair()).resolves.toEqual({
enqueued: 0,
skipped: 0,
error: null,
});
expect(bossSend).not.toHaveBeenCalled();
await expect(runFitbitSleepRepairForUser("u1")).resolves.toEqual({
imported: 0,
removed: 0,
});
expect(syncUserSleep).not.toHaveBeenCalled();
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
});
@@ -0,0 +1,47 @@
/**
* The hourly Fitbit cron after Google turned the Web API off: it reads no
* connection, runs no cohort, and finishes as a clean no-op, so the shutdown
* cannot turn into an hourly storm of failed polls.
*/
import { describe, expect, it, vi } from "vitest";
const { findMany, runFitbitPollCohort } = vi.hoisted(() => ({
findMany: vi.fn(async () => [{ userId: "u1" }]),
runFitbitPollCohort: vi.fn(async () => ({
usersSynced: 1,
measurementsImported: 3,
})),
}));
vi.mock("@/lib/jobs/reminder/shared", () => ({
getWorkerPrisma: () => ({ fitbitConnection: { findMany } }),
}));
vi.mock("@/lib/fitbit/sync", () => ({ runFitbitPollCohort }));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn(async () => {}),
}));
import { handleFitbitSync } from "../reminder/fitbit-sync";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
describe("handleFitbitSync", () => {
it("polls the cohort while the Web API is up", async () => {
await handleFitbitSync([]);
expect(findMany).toHaveBeenCalledTimes(1);
expect(runFitbitPollCohort).toHaveBeenCalledTimes(1);
});
it("does nothing at all after the turndown", async () => {
findMany.mockClear();
runFitbitPollCohort.mockClear();
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
const outcome = await handleFitbitSync([]);
expect(findMany).not.toHaveBeenCalled();
expect(runFitbitPollCohort).not.toHaveBeenCalled();
expect(outcome).toMatchObject({ ok: true, did: { total: 0 } });
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
});
+13 -1
View File
@@ -20,6 +20,7 @@ import {
withPageWalkLedger,
} from "@/lib/integrations/page-walk";
import { syncUserFitbit } from "@/lib/fitbit/sync";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
export const FITBIT_BACKFILL_QUEUE = "fitbit-backfill";
@@ -58,6 +59,17 @@ export interface FitbitBackfillPayload {
export async function runFitbitBackfillForUser(
userId: string,
): Promise<{ imported: number }> {
// A job queued before the turndown: the history it would walk is gone with
// the API. Returns without stamping, so the marker never claims a walk.
if (isFitbitRetired()) {
annotate({
action: {
name: "fitbit.backfill.skipped_retired",
details: { imported: 0 },
},
});
return { imported: 0 };
}
if (await isReauthRequired(userId, "fitbit")) {
annotate({
action: {
@@ -133,7 +145,7 @@ export async function enqueueBootTimeFitbitBackfill(
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
if (!boss || isFitbitRetired()) {
return { enqueued: 0, skipped: 0, error: null };
}
+12 -1
View File
@@ -45,6 +45,7 @@ import {
runWithFitbitHardFailLedger,
} from "@/lib/fitbit/sync-core";
import { syncUserSleep } from "@/lib/fitbit/sync-sleep";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
export const FITBIT_SLEEP_REPAIR_QUEUE = "fitbit-sleep-repair";
@@ -82,6 +83,16 @@ export interface FitbitSleepRepairPayload {
export async function runFitbitSleepRepairForUser(
userId: string,
): Promise<{ imported: number; removed: number }> {
// Nothing left to re-read once the Web API is gone; no stamp either.
if (isFitbitRetired()) {
annotate({
action: {
name: "fitbit.sleepRepair.skipped_retired",
details: { imported: 0, removed: 0 },
},
});
return { imported: 0, removed: 0 };
}
if (await isReauthRequired(userId, "fitbit")) {
annotate({
action: {
@@ -155,7 +166,7 @@ export async function enqueueBootTimeFitbitSleepRepair(
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
if (!boss || isFitbitRetired()) {
return { enqueued: 0, skipped: 0, error: null };
}
@@ -17,20 +17,30 @@ import { syncUserPolarLegs } from "../polar-sync";
beforeEach(() => {
vi.clearAllMocks();
syncUserPolar.mockResolvedValue({ imported: 0, failed: false });
syncUserPolar.mockResolvedValue({
imported: 0,
failed: false,
tokenProven: true,
});
syncUserPolarWorkouts.mockResolvedValue(0);
});
describe("syncUserPolarLegs — composite independence on the shared polar ledger", () => {
it("runs both legs and returns the summed count when both pass", async () => {
syncUserPolar.mockResolvedValue({ imported: 5, failed: false });
syncUserPolar.mockResolvedValue({
imported: 5,
failed: false,
tokenProven: true,
});
syncUserPolarWorkouts.mockResolvedValue(2);
await expect(syncUserPolarLegs("u1")).resolves.toEqual({
imported: 7,
failed: false,
});
expect(syncUserPolar).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1", {
tokenProven: true,
});
});
it("sums both counts without throwing when the vitals leg returns a partial-failure count", async () => {
@@ -38,7 +48,11 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge
// its healthy-collections import count rather than throwing. The wrapper
// must sum it with the workout leg and complete cleanly — a partial vitals
// failure never starves the user's success accounting at the cohort level.
syncUserPolar.mockResolvedValue({ imported: 4, failed: true });
syncUserPolar.mockResolvedValue({
imported: 4,
failed: true,
tokenProven: true,
});
syncUserPolarWorkouts.mockResolvedValue(2);
// The partial is carried, not swallowed: the wrapper completes with the
// summed count AND says a leg did not settle, so the card cannot paint a
@@ -48,7 +62,9 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge
failed: true,
});
expect(syncUserPolar).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1", {
tokenProven: true,
});
});
it("still attempts the workout leg when the vitals leg fails (vice-versa isolation)", async () => {
@@ -57,11 +73,17 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge
// The vitals error is rethrown (attributable), but only AFTER the workout
// leg was attempted — a vitals failure never starves the workout leg.
await expect(syncUserPolarLegs("u1")).rejects.toThrow("vitals down");
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1", {
tokenProven: false,
});
});
it("leaves the vitals leg's result intact when the workout leg fails", async () => {
syncUserPolar.mockResolvedValue({ imported: 4, failed: false });
syncUserPolar.mockResolvedValue({
imported: 4,
failed: false,
tokenProven: true,
});
syncUserPolarWorkouts.mockRejectedValue(new Error("workouts down"));
// The vitals leg ran to completion (its own success already recorded on
// the shared ledger); the workout error is surfaced without undoing it.
@@ -73,6 +95,8 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge
syncUserPolar.mockRejectedValue(new Error("vitals first"));
syncUserPolarWorkouts.mockRejectedValue(new Error("workouts second"));
await expect(syncUserPolarLegs("u1")).rejects.toThrow("vitals first");
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1", {
tokenProven: false,
});
});
});
+15
View File
@@ -13,6 +13,7 @@ import { runFitbitPollCohort } from "@/lib/fitbit/sync";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { cleanupExpiredFitbitOAuthStates } from "@/lib/jobs/fitbit-oauth-state-cleanup";
import { getWorkerPrisma } from "./shared";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
/**
* v1.12.0 — Fitbit poll-sync payload. Poll-only (no webhook at launch): the
@@ -28,6 +29,20 @@ export async function handleFitbitSync(
jobs: Job<FitbitSyncPayload>[],
): Promise<JobOutcome> {
return withBackgroundEvent("job.fitbit_sync", async (evt) => {
// Google turned the Web API off (`@/lib/fitbit/retirement`): the cron
// tick reads no connection and calls nothing.
if (isFitbitRetired()) {
evt.setBackground({
task_name: "job.fitbit_sync",
result: { retired: true },
});
return jobDone({
total: 0,
users_synced: 0,
users_failed: 0,
measurements_imported: 0,
});
}
const prisma = getWorkerPrisma();
try {
const targets: Array<{ userId: string }> = [];
+6 -3
View File
@@ -23,8 +23,9 @@ export type PolarSyncPayload = PollCohortPayload;
/**
* Run both Polar legs for one user with INDEPENDENT attribution on the shared
* `polar` ledger. Sequentially awaited (not `Promise.all`) so a workouts error
* stays attributable. Each leg records its own failure; the vitals leg owns the
* single success write. A failure in either leg is isolated — both legs are
* stays attributable, and so the vitals leg can tell the workouts leg whether
* the token just worked (a 403 on exercises is then a per-type refusal). Each
* leg records its own failure and its own success. A failure in either leg is isolated — both legs are
* always attempted, so a workouts-leg failure can never mask a vitals success
* and vice versa — then the FIRST error is rethrown so the cohort handler warns
* and skips this user's success accounting without losing the error.
@@ -45,18 +46,20 @@ export async function syncUserPolarLegs(
// it has to be carried explicitly — otherwise a run in which four of five
// collections failed reads exactly like a clean one.
let partial = false;
let tokenProven = false;
try {
const vitals = await syncUserPolar(userId);
total += vitals.imported;
partial = vitals.failed;
tokenProven = vitals.tokenProven;
} catch (err) {
firstError = err;
hadError = true;
}
try {
total += await syncUserPolarWorkouts(userId);
total += await syncUserPolarWorkouts(userId, { tokenProven });
} catch (err) {
if (!hadError) {
firstError = err;
+19 -4
View File
@@ -93,7 +93,7 @@ export async function runStravaBackfillForUser(
const { result, truncated } = await withPageWalkLedger(() =>
syncStravaHistory(userId, before),
);
const { imported, oldestStart } = result;
const { imported, oldestStart, retryAfterSeconds } = result;
// A failure has already thrown; what is left to decide is whether the walk
// reached the beginning of the history.
if (backfillVerdict({ failed: false, truncated }) === "incomplete") {
@@ -111,7 +111,18 @@ export async function runStravaBackfillForUser(
data: { stravaBackfillResumeBefore: resumeBefore },
});
}
const continued = progressed && (await sendNextRun(userId, slice));
// A run Strava's rate limit ended goes on once the window it named has
// reset, progress or not: the limit is the app's, and passes.
const continued =
(progressed || retryAfterSeconds !== undefined) &&
(await sendNextRun(
userId,
slice,
Math.max(
STRAVA_BACKFILL_CONTINUATION_DELAY_SECONDS,
retryAfterSeconds ?? 0,
),
));
annotate({
action: {
name: "strava.backfill.truncated",
@@ -143,7 +154,11 @@ export async function runStravaBackfillForUser(
return { imported, continued: false };
}
async function sendNextRun(userId: string, slice: number): Promise<boolean> {
async function sendNextRun(
userId: string,
slice: number,
delaySeconds: number,
): Promise<boolean> {
const boss = getGlobalBoss();
if (!boss) return false;
const sent = await enqueueIntegrationBackfillAdmission(
@@ -152,7 +167,7 @@ async function sendNextRun(userId: string, slice: number): Promise<boolean> {
kind: "strava-backfill",
data: { userId, enqueuedAt: new Date().toISOString(), slice: slice + 1 },
},
STRAVA_BACKFILL_CONTINUATION_DELAY_SECONDS,
delaySeconds,
);
return Boolean(sent);
}
+2
View File
@@ -212,6 +212,8 @@ const STATIC_SURFACE_MODULE = {
"correlation:SLEEP_DURATION": "sleep",
"correlation:BLOOD_GLUCOSE": "glucose",
"correlation:RESTING_HEART_RATE": "recovery",
// The RMSSD series is the recovery module's (see `summary:HRV_RMSSD`).
"correlation:HRV_RMSSD": "recovery",
"correlation:MEDICATION_COMPLIANCE": "medications",
// Symptom burden is read from the illness journal.
"correlation:SYMPTOM_SEVERITY": "illness",
+1
View File
@@ -486,6 +486,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"oidc_only",
"private_origin_not_approved",
"private_origin_not_grantable",
"provider_retired",
"rate_limited_self",
"sync_in_progress",
"too_many_rows",
+30 -1
View File
@@ -1151,10 +1151,29 @@ const integrationEnvelopeEntry = z
),
hasToken: z.boolean().optional().describe("Nightscout only."),
allowPrivateHost: z.boolean().optional().describe("Nightscout only."),
retirement: z
.object({
endsOn: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe("The day Google turns the legacy Fitbit Web API off."),
ended: z
.boolean()
.describe(
"The day has come: the connection no longer syncs, its imported data stays, and `health` is null.",
),
successor: z
.enum(["google-health"])
.describe(
"The integration that reads the same Fitbit and Pixel devices. Moving needs a fresh consent; the Fitbit grant cannot be carried over.",
),
})
.optional()
.describe("Fitbit only. The end of the legacy Fitbit Web API."),
health: connectionHealth
.nullable()
.describe(
"The unified connection state, null when the provider is not connected. It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and removed in v1.44.",
"The unified connection state, null when the provider is not connected (and for Fitbit once `retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and removed in v1.44.",
),
})
.meta({
@@ -2020,6 +2039,11 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Either limiter tripped: more than 5 syncs in 60 s, or a second full sync within the hour. `meta.errorCode` = `rate_limited_self` for both; the message distinguishes them.",
content: { "application/json": { schema: errorEnvelope } },
},
"410": {
description:
"Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is fetched. `meta.errorCode` = `provider_retired`. The data already imported stays; Google Health reads the same devices.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
@@ -2663,6 +2687,11 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
...probeNotConfigured422,
...selfRateLimited429,
"410": {
description:
"Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is probed. `meta.errorCode` = `provider_retired`.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
+53 -7
View File
@@ -9,6 +9,7 @@ const {
getPolarConnection,
fetchExercises,
recordSyncFailure,
recordSyncSuccess,
} = vi.hoisted(() => ({
createManyAndReturn: vi.fn(),
update: vi.fn(),
@@ -16,6 +17,7 @@ const {
getPolarConnection: vi.fn(),
fetchExercises: vi.fn(),
recordSyncFailure: vi.fn(async () => {}),
recordSyncSuccess: vi.fn(async () => {}),
}));
vi.mock("@/lib/db", () => ({
@@ -32,6 +34,7 @@ vi.mock("../credentials", () => ({ getPolarConnection }));
vi.mock("@/lib/integrations/status", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/integrations/status")>()),
recordSyncFailure,
recordSyncSuccess,
}));
vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal<typeof import("../client")>();
@@ -181,9 +184,9 @@ describe("syncUserPolarWorkouts", () => {
);
});
// Assumption #4: a 403 scope gap is a soft/recoverable ledger error, not a
// hard crash — recorded as reauth_required and rethrown for cohort isolation.
it("records a 403 scope error as a soft reauth_required failure", async () => {
// Without a read that proved the token, a 403 may be the account-wide
// consent gap: recorded as reauth_required and rethrown for cohort isolation.
it("records a 403 as a reauth_required failure when nothing proved the token", async () => {
fetchExercises.mockRejectedValue(
new PolarApiError({
verb: "fetchExercises",
@@ -204,11 +207,54 @@ describe("syncUserPolarWorkouts", () => {
);
});
it("never records success on the shared ledger (vitals leg owns the single success write)", async () => {
const status = await import("@/lib/integrations/status");
const spy = vi.spyOn(status, "recordSyncSuccess");
it("reads a 403 as a refused data type once the vitals leg proved the token", async () => {
fetchExercises.mockRejectedValue(
new PolarApiError({
verb: "fetchExercises",
classification: "reauth_required",
httpStatus: 403,
reason: "http_403",
}),
);
await expect(
syncUserPolarWorkouts("u1", { tokenProven: true }),
).resolves.toBe(0);
expect(recordSyncFailure).not.toHaveBeenCalled();
expect(recordSyncSuccess).toHaveBeenCalledWith("u1", "polar", {
leg: "workouts",
});
});
it("keeps a 401 a failure even after the vitals leg proved the token", async () => {
fetchExercises.mockRejectedValue(
new PolarApiError({
verb: "fetchExercises",
classification: "reauth_required",
httpStatus: 401,
reason: "http_401",
}),
);
await expect(
syncUserPolarWorkouts("u1", { tokenProven: true }),
).rejects.toBeInstanceOf(PolarApiError);
expect(recordSyncFailure).toHaveBeenCalledTimes(1);
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
// A leg-scoped success clears only its own leg, so this leg has to write
// its own: left to the vitals leg, one workouts failure stayed on the row
// for good.
it("records its own leg's success after a clean read", async () => {
fetchExercises.mockResolvedValue([]);
await syncUserPolarWorkouts("u1");
expect(spy).not.toHaveBeenCalled();
expect(recordSyncSuccess).toHaveBeenCalledWith("u1", "polar", {
leg: "workouts",
});
});
it("records no success when the read failed", async () => {
fetchExercises.mockRejectedValue(new Error("boom"));
await expect(syncUserPolarWorkouts("u1")).rejects.toThrow("boom");
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
});
+71 -11
View File
@@ -164,7 +164,11 @@ function createdRows() {
describe("syncUserPolar", () => {
it("no-ops cleanly for an unconnected user", async () => {
getConnMock.mockResolvedValue(null);
expect(await syncUserPolar("u1")).toEqual({ imported: 0, failed: false });
expect(await syncUserPolar("u1")).toEqual({
imported: 0,
failed: false,
tokenProven: false,
});
expect(createManyAndReturnMock).not.toHaveBeenCalled();
expect(recordSuccessMock).not.toHaveBeenCalled();
});
@@ -370,13 +374,13 @@ describe("syncUserPolar", () => {
it("isolates one dead collection — imports the healthy four and records one named failure", async () => {
getConnMock.mockResolvedValue(CONN);
// SpO2 403s (no sensor / consent), the other four return one row each.
// SpO2 fails upstream, the other four return one row each.
fetchSpo2Mock.mockRejectedValue(
new PolarApiError({
verb: "fetchSpo2",
classification: "reauth_required",
httpStatus: 403,
reason: "http_403",
classification: "transient",
httpStatus: 503,
reason: "http_503",
}),
);
fetchRechargesMock.mockResolvedValue([
@@ -402,6 +406,7 @@ describe("syncUserPolar", () => {
// The partial rides back with the count rather than being narrowed away.
expect(result.failed).toBe(true);
expect(result.tokenProven).toBe(true);
// The healthy four still wrote their rows — the source is not blanked.
const written = createdRows().map((row) => row.type);
expect(written).toContain("RECOVERY_SCORE");
@@ -410,19 +415,74 @@ describe("syncUserPolar", () => {
expect(written).toContain("CARDIO_LOAD");
expect(written).not.toContain("OXYGEN_SATURATION");
expect(imported).toBeGreaterThan(0);
// Exactly one failure, classified off the 403, naming spo2. No success.
// Exactly one failure, naming spo2. No success.
expect(recordFailureMock).toHaveBeenCalledTimes(1);
expect(recordFailureMock).toHaveBeenCalledWith(
expect.objectContaining({
integration: "polar",
kind: "reauth_required",
errorCode: "403",
kind: "transient",
errorCode: "503",
message: expect.stringContaining("spo2"),
}),
);
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("reads a 403 beside a healthy sibling as a refused data type, not a dead token", async () => {
getConnMock.mockResolvedValue(CONN);
// An account without the SpO2 sensor or the consent for it: the same token
// reads the other collections fine.
fetchSpo2Mock.mockRejectedValue(
new PolarApiError({
verb: "fetchSpo2",
classification: "reauth_required",
httpStatus: 403,
reason: "http_403",
}),
);
fetchCardioLoadsMock.mockResolvedValue([
{ date: "2026-06-10", cardio_load: 55 },
]);
const result = await syncUserPolar("u1");
expect(result).toMatchObject({ failed: false, tokenProven: true });
expect(createdRows().map((row) => row.type)).toContain("CARDIO_LOAD");
expect(recordFailureMock).not.toHaveBeenCalled();
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "polar", {
leg: "vitals",
});
});
it("keeps a 403 on every collection a reauth_required failure", async () => {
getConnMock.mockResolvedValue(CONN);
for (const mock of [
fetchRechargesMock,
fetchSleepsMock,
fetchActivitiesMock,
fetchCardioLoadsMock,
fetchSpo2Mock,
]) {
mock.mockRejectedValue(
new PolarApiError({
verb: "fetch",
classification: "reauth_required",
httpStatus: 403,
reason: "http_403",
}),
);
}
const result = await syncUserPolar("u1");
expect(result).toMatchObject({ failed: true, tokenProven: false });
expect(recordFailureMock).toHaveBeenCalledTimes(1);
expect(recordFailureMock).toHaveBeenCalledWith(
expect.objectContaining({ kind: "reauth_required", errorCode: "403" }),
);
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("isolates a throwing mapper — one malformed record does not blank siblings", async () => {
getConnMock.mockResolvedValue(CONN);
// A malformed recharge record whose mapper throws on first field access
@@ -534,9 +594,9 @@ describe("syncUserPolar", () => {
mockByName[name]!.mockRejectedValue(
new PolarApiError({
verb: name,
classification: "reauth_required",
httpStatus: 403,
reason: "http_403",
classification: "persistent",
httpStatus: 404,
reason: "http_404",
}),
);
+11
View File
@@ -60,6 +60,17 @@ export function isInvalidGrant(err: unknown): boolean {
return isOAuthInvalidGrant(err, "Polar");
}
/**
* True for a collection read Polar refused with 403. AccessLink documents its
* 403 as "User has not accepted all mandatory consents": the token is good,
* the account does not share this one data type (or has nothing behind it).
* Only meaningful next to a read that succeeded with the same token — a 403 on
* every collection is the account-wide consent gap, and stays a failure.
*/
export function isPolarCollectionRefusal(err: unknown): boolean {
return err instanceof PolarApiError && err.httpStatus === 403;
}
/**
* Read the classification from any caught error. A non-`PolarApiError` defaults
* to `transient` so a plain `Error` retries rather than disabling the
+28 -13
View File
@@ -21,12 +21,13 @@
* only write and it is idempotent — no cursor, no backfill queue (the API caps
* history at 30 days, so first-connect backfill IS the first regular tick).
*
* Ledger: workouts and vitals share the single `polar` integration ledger. This
* leg records only its OWN FAILURE (and rethrows so the cohort handler can
* attribute it); the single success write stays the vitals leg's job so a fully
* successful pass records success exactly once. A workouts-leg failure records a
* transient/reauth failure on the shared ledger but never touches the vitals
* success timestamp (`recordSyncFailure` leaves `lastSuccessAt` intact).
* Ledger: workouts and vitals share the single `polar` integration ledger, each
* under its own leg. This leg records its own failure (and rethrows so the
* cohort handler can attribute it) and its own success. The success used to be
* left to the vitals leg, but a leg-scoped success clears only its own leg, so
* a workouts failure stayed on the row for good once recorded: every later
* vitals success saw the workouts leg still failing and kept the connection in
* error, however healthy the exercises read had become.
*/
import { prisma } from "@/lib/db";
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
@@ -35,11 +36,16 @@ import { annotate, getEvent } from "@/lib/logging/context";
import {
markSyncFailureRecorded,
recordSyncFailure,
recordSyncSuccess,
toFailureKind,
} from "@/lib/integrations/status";
import { fetchExercises, mapExercise, type PolarWorkoutRow } from "./client";
import { getPolarConnection } from "./credentials";
import { PolarApiError, classifyPolarError } from "./response-classifier";
import {
PolarApiError,
classifyPolarError,
isPolarCollectionRefusal,
} from "./response-classifier";
const POLAR_LEG_WORKOUTS = "workouts";
@@ -70,13 +76,16 @@ async function recordPolarWorkoutFailure(
* written. A user with no Polar connection is a clean no-op (returns 0, touches
* no status row).
*
* A 403 on the exercises read (a scope gap under `accesslink.read_all`) is
* classified through the shared response classifier as `reauth_required` — a
* SOFT/recoverable integration error surfaced on the status ledger, NOT a hard
* crash: it is recorded and rethrown so the composite cohort pass isolates this
* user and the vitals leg's own result is untouched.
* A 403 on the exercises read is a refusal when the vitals leg proved the token
* in the same run (`tokenProven`): the account does not share exercises, which
* no reconnect changes, so the leg reads nothing and stays healthy. Without
* that proof the 403 is classified `reauth_required` as before, recorded, and
* rethrown so the composite cohort pass isolates this user.
*/
export async function syncUserPolarWorkouts(userId: string): Promise<number> {
export async function syncUserPolarWorkouts(
userId: string,
opts: { tokenProven?: boolean } = {},
): Promise<number> {
const conn = await getPolarConnection(userId);
if (!conn) return 0;
@@ -91,6 +100,11 @@ export async function syncUserPolarWorkouts(userId: string): Promise<number> {
if (row) rows.push(row);
}
} catch (err) {
if (opts.tokenProven && isPolarCollectionRefusal(err)) {
annotate({ meta: { polar_collections_refused: [POLAR_LEG_WORKOUTS] } });
await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_WORKOUTS });
return 0;
}
await recordPolarWorkoutFailure(userId, err);
throw markSyncFailureRecorded(err);
}
@@ -110,6 +124,7 @@ export async function syncUserPolarWorkouts(userId: string): Promise<number> {
invalidateUserMeasurements(userId);
}
await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_WORKOUTS });
return imported;
}
+62 -14
View File
@@ -13,12 +13,19 @@
* exactly like Nightscout's token-rejected path.
*
* Isolation: the five vitals collections are settled independently
* (`Promise.allSettled`), so a single dead collection — e.g. a 403 on SpO2 or
* cardio-load on an account without the sensor or the higher tier — imports the
* healthy four and records ONE partial failure rather than blanking every
* collection. Mirrors Oura's shipped per-collection isolation, minus the
* token-refresh leg Polar structurally lacks (a 401 is just a per-collection
* failure the classifier maps to `reauth_required`).
* (`Promise.allSettled`), so a single dead collection imports the healthy four
* and records ONE partial failure rather than blanking every collection.
* Mirrors Oura's shipped per-collection isolation, minus the token-refresh leg
* Polar structurally lacks (a 401 is just a per-collection failure the
* classifier maps to `reauth_required`).
*
* A 403 on one collection while a sibling succeeded is a refusal, not a
* failure (`isPolarCollectionRefusal`): the same token just worked, so the
* account simply does not share that data type (SpO2 without the sensor,
* cardio load without the tier). Counting it as `reauth_required` put a
* working connection behind a "reconnect" prompt that no reconnect clears.
* The refused names ride the wide event; the leg records success. Mirrors
* Oura's handling of a scope the grant does not cover.
*
* Window contract: every hourly tick re-reads the full server-fixed window for
* each collection — 28 days for recharge / sleep / activity / cardio-load /
@@ -39,7 +46,7 @@
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { getEvent } from "@/lib/logging/context";
import { annotate, getEvent } from "@/lib/logging/context";
import {
recordSyncFailure,
recordSyncSuccess,
@@ -80,7 +87,11 @@ import {
type SleepSegmentSweep,
} from "@/lib/sleep/sweep-stale-segments";
import { getPolarConnection } from "./credentials";
import { PolarApiError, classifyPolarError } from "./response-classifier";
import {
PolarApiError,
classifyPolarError,
isPolarCollectionRefusal,
} from "./response-classifier";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/** Map a Polar error onto the shared integration-ledger failure kind. */
@@ -164,6 +175,15 @@ export interface PolarCollectionFailure {
err: unknown;
}
/** The vitals leg's outcome, plus whether any collection proved the token. */
export interface PolarVitalsResult extends SyncWriteResult {
/**
* True when at least one collection read succeeded with the token, so a 403
* elsewhere in this run (the workouts leg included) is a per-type refusal.
*/
tokenProven: boolean;
}
/**
* Sync one user's Polar data. Returns the rows written AND whether a
* collection did not settle — the bare count used to narrow the partial away,
@@ -177,9 +197,11 @@ export interface PolarCollectionFailure {
* escapes now is a write-path/unexpected error out of `upsertPolarMeasurements`
* — the poll-cohort boundary records that unmarked escape once.
*/
export async function syncUserPolar(userId: string): Promise<SyncWriteResult> {
export async function syncUserPolar(
userId: string,
): Promise<PolarVitalsResult> {
const conn = await getPolarConnection(userId);
if (!conn) return { imported: 0, failed: false };
if (!conn) return { imported: 0, failed: false, tokenProven: false };
// Date-keyed rows are anchored at local noon of their date in this zone.
const tz = await resolveUserTimezone(userId);
@@ -250,15 +272,23 @@ export async function syncUserPolar(userId: string): Promise<SyncWriteResult> {
collections.map((spec) => spec.collect(conn.accessToken)),
);
const readings: PolarMeasurementUpsert[] = [];
const failures: PolarCollectionFailure[] = [];
const settledFailures: PolarCollectionFailure[] = [];
settled.forEach((res, i) => {
const spec = collections[i]!;
if (res.status === "fulfilled") {
readings.push(...res.value);
} else {
failures.push({ name: spec.name, err: res.reason });
settledFailures.push({ name: spec.name, err: res.reason });
}
});
const tokenProven = settled.some((res) => res.status === "fulfilled");
const { failures, refused } = splitPolarRefusals(
settledFailures,
tokenProven,
);
if (refused.length > 0) {
annotate({ meta: { polar_collections_refused: refused } });
}
// Clear whatever an earlier scoring left under the re-fetched nights before
// the fresh set upserts (mirrors Google Health's replace-by-window order).
@@ -317,11 +347,29 @@ export async function syncUserPolar(userId: string): Promise<SyncWriteResult> {
? String(firstErr.httpStatus)
: undefined,
});
return { imported, failed: true };
return { imported, failed: true, tokenProven };
}
await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_VITALS });
return { imported, failed: false };
return { imported, failed: false, tokenProven };
}
/**
* Split a run's collection failures into the ones that count and the per-type
* refusals (a 403 while another read proved the token). Without a proof every
* failure counts: a 403 on every collection is the account-wide consent gap.
*/
export function splitPolarRefusals(
failures: PolarCollectionFailure[],
tokenProven: boolean,
): { failures: PolarCollectionFailure[]; refused: string[] } {
if (!tokenProven) return { failures, refused: [] };
return {
failures: failures.filter((f) => !isPolarCollectionRefusal(f.err)),
refused: failures
.filter((f) => isPolarCollectionRefusal(f.err))
.map((f) => f.name),
};
}
/**
@@ -0,0 +1,279 @@
/**
* Strava's rate limit and its detail budget.
*
* - A 429 mid-walk used to discard everything the run had read: the error
* threw out of the walk before the upsert. The activities read before it
* land now, the cursor moves only as far as an oldest-first walk proves
* safe, the failure is booked as transient, and the backfill waits for
* the window Strava's headers name before it goes on.
* - An update mapped from the summary alone (detail budget spent, or a
* detail read that failed) wrote `totalEnergyKcal: null` over the
* calories an earlier detail read had stored. The fields only the detail
* carries are kept when this run did not read it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
fetchActivities: vi.fn(),
fetchActivityById: vi.fn(),
annotate: vi.fn(),
findUser: vi.fn(),
updateUser: vi.fn(),
updateManyUser: vi.fn(async (_arg: unknown) => ({ count: 1 })),
createManyAndReturn: vi.fn(),
updateWorkout: vi.fn(async (_arg: { data: Record<string, unknown> }) => ({})),
findWorkout: vi.fn(),
recordSyncFailure: vi.fn(async () => {}),
recordSyncSuccess: vi.fn(async () => {}),
isReauthRequired: vi.fn(async () => false),
send: vi.fn(async (..._args: unknown[]) => "job-next"),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: mocks.findUser,
updateMany: mocks.updateManyUser,
update: mocks.updateUser,
},
workout: {
createManyAndReturn: mocks.createManyAndReturn,
update: mocks.updateWorkout,
findUnique: mocks.findWorkout,
},
},
}));
vi.mock("@/lib/arrivals/workout-emit", () => ({
emitInsertedWorkoutArrival: vi.fn(async () => {}),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: mocks.annotate,
getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }),
}));
vi.mock("@/lib/integrations/status", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/integrations/status")>()),
recordSyncFailure: mocks.recordSyncFailure,
recordSyncSuccess: mocks.recordSyncSuccess,
isReauthRequired: mocks.isReauthRequired,
}));
vi.mock("../credentials", () => ({
getStravaConnection: vi.fn(async () => ({
accessToken: "access-token",
refreshToken: "refresh-token",
refreshTokenCiphertext: Buffer.from("ciphertext"),
})),
getStravaClientCredentials: vi.fn(),
storeStravaTokens: vi.fn(),
}));
vi.mock("../client", async (importOriginal) => ({
...(await importOriginal<typeof import("../client")>()),
fetchActivities: mocks.fetchActivities,
fetchActivityById: mocks.fetchActivityById,
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(() => ({ send: mocks.send })),
}));
import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill";
import { StravaApiError } from "../response-classifier";
import { stravaRateLimitRetryAfterSeconds } from "../client";
import { syncUserStrava } from "../sync";
const HOUR = 3_600_000;
const T0 = Date.UTC(2026, 9, 1);
function activity(id: number) {
return {
id,
sport_type: "Run",
start_date: new Date(T0 + id * HOUR).toISOString(),
moving_time: 1800,
elapsed_time: 1900,
average_heartrate: 140,
};
}
function rateLimited(retryAfterSeconds: number): StravaApiError {
return new StravaApiError({
verb: "fetchActivities",
classification: "transient",
httpStatus: 429,
reason: "http_429",
retryAfterSeconds,
});
}
beforeEach(() => {
vi.clearAllMocks();
mocks.findUser.mockResolvedValue({
stravaLastActivityAt: new Date(T0),
stravaBackfillResumeBefore: null,
});
mocks.createManyAndReturn.mockImplementation(
async ({ data }: { data: { externalId: string; startedAt: Date } }) => [
{ id: `w-${data.externalId}`, startedAt: data.startedAt },
],
);
mocks.fetchActivityById.mockImplementation(
async (_t: string, id: number) => ({
...activity(id),
calories: 300,
}),
);
});
describe("a 429 in the middle of a walk", () => {
it("keeps what was read, moves the cursor an oldest-first walk proves, and books it transient", async () => {
// An incremental walk (`after` set): Strava answers oldest first. Three
// activities read, then the detail read of the fourth is refused.
mocks.fetchActivities.mockResolvedValue([1, 2, 3, 4, 5].map(activity));
mocks.fetchActivityById.mockImplementation(
async (_t: string, id: number) => {
if (id === 4) throw rateLimited(600);
return { ...activity(id), calories: 300 };
},
);
await expect(syncUserStrava("user-1")).resolves.toBe(3);
expect(mocks.createManyAndReturn).toHaveBeenCalledTimes(3);
expect(mocks.updateManyUser).toHaveBeenCalledWith(
expect.objectContaining({
data: { stravaLastActivityAt: new Date(T0 + 3 * HOUR) },
}),
);
expect(mocks.recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({ kind: "transient", errorCode: "429" }),
);
expect(mocks.recordSyncSuccess).not.toHaveBeenCalled();
});
it("does not move the cursor past a newest-first walk it did not finish", async () => {
mocks.fetchActivities.mockResolvedValue([9, 8, 7].map(activity));
mocks.fetchActivityById.mockImplementation(
async (_t: string, id: number) => {
if (id === 7) throw rateLimited(600);
return { ...activity(id), calories: 300 };
},
);
await expect(syncUserStrava("user-1")).resolves.toBe(2);
expect(mocks.updateManyUser).not.toHaveBeenCalled();
});
it("sends the next backfill run after the window Strava named", async () => {
// A history walk (no `after`): newest first, refused on the second page.
mocks.fetchActivities.mockImplementation(
async (_t: string, q: { page: number }) => {
if (q.page === 2) throw rateLimited(5400);
return Array.from({ length: 100 }, (_, i) => activity(500 - i));
},
);
const run = await runStravaBackfillForUser("user-1");
expect(run).toEqual({ imported: 100, continued: true });
expect(mocks.updateUser).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
stravaBackfillResumeBefore: new Date(T0 + 401 * HOUR + 1000),
},
});
const [, , options] = mocks.send.mock.calls[0] as unknown as [
string,
unknown,
{ startAfter?: number },
];
expect(options.startAfter).toBe(5400);
expect(mocks.updateUser).not.toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
stravaBackfillCompletedAt: expect.any(Date),
}),
}),
);
});
});
describe("an update without the detail read", () => {
it("keeps the stored calories, heart rate and description", async () => {
// The row exists already; this run reads only the summary (no HR on it)
// because the detail read failed.
mocks.createManyAndReturn.mockResolvedValue([]);
mocks.fetchActivities.mockResolvedValue([
{ ...activity(1), average_heartrate: undefined },
]);
mocks.fetchActivityById.mockRejectedValue(new Error("detail down"));
mocks.findWorkout.mockResolvedValue({
metadata: { stravaName: "old", stravaDescription: "Hill reps" },
});
await syncUserStrava("user-1");
const data = mocks.updateWorkout.mock.calls[0]![0].data as Record<
string,
unknown
>;
expect(data).not.toHaveProperty("totalEnergyKcal");
expect(data).not.toHaveProperty("avgHeartRate");
expect(data).not.toHaveProperty("maxHeartRate");
expect(data.metadata).toMatchObject({ stravaDescription: "Hill reps" });
expect(data.durationSec).toBe(1800);
});
it("writes what a detail read returned, nulls included", async () => {
mocks.createManyAndReturn.mockResolvedValue([]);
mocks.fetchActivities.mockResolvedValue([activity(1)]);
mocks.fetchActivityById.mockResolvedValue({
...activity(1),
calories: null,
});
await syncUserStrava("user-1");
const data = mocks.updateWorkout.mock.calls[0]![0].data as Record<
string,
unknown
>;
expect(data.totalEnergyKcal).toBeNull();
expect(mocks.findWorkout).not.toHaveBeenCalled();
});
});
describe("stravaRateLimitRetryAfterSeconds", () => {
const at = new Date("2026-10-21T10:07:30Z");
it("waits for the next quarter hour when the short window is spent", () => {
const headers = new Headers({
"X-RateLimit-Limit": "200,2000",
"X-RateLimit-Usage": "200,950",
});
expect(stravaRateLimitRetryAfterSeconds(headers, at)).toBe(450);
});
it("waits for midnight UTC when the daily limit is spent", () => {
const headers = new Headers({
"X-ReadRateLimit-Limit": "100,1000",
"X-ReadRateLimit-Usage": "40,1000",
});
expect(stravaRateLimitRetryAfterSeconds(headers, at)).toBe(
13 * 3600 + 52 * 60 + 30,
);
});
it("honours a Retry-After header when one is sent", () => {
expect(
stravaRateLimitRetryAfterSeconds(
new Headers({ "Retry-After": "120" }),
at,
),
).toBe(120);
});
it("falls back to the next quarter hour without any header", () => {
expect(stravaRateLimitRetryAfterSeconds(new Headers(), at)).toBe(450);
});
});
+51
View File
@@ -258,11 +258,54 @@ async function stravaGet<T>(
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
...(res.status === 429
? { retryAfterSeconds: stravaRateLimitRetryAfterSeconds(res.headers) }
: {}),
});
}
return (await res.json().catch(() => null)) as T;
}
const QUARTER_HOUR_MS = 15 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* How long to wait after a 429, from the response's own headers. Strava sends
* no `Retry-After` (one is honoured should it appear); it sends
* `X-RateLimit-Limit` / `X-RateLimit-Usage` and, for reads,
* `X-ReadRateLimit-Limit` / `X-ReadRateLimit-Usage`, each `<15-min>,<daily>`.
* The 15-minute window resets at :00, :15, :30 and :45, the daily one at
* midnight UTC (developers.strava.com/docs/rate-limits). A spent daily count
* waits for midnight; anything else, the next quarter hour.
*/
export function stravaRateLimitRetryAfterSeconds(
headers: Headers,
now: Date = new Date(),
): number {
const retryAfter = Number(headers.get("retry-after"));
if (Number.isFinite(retryAfter) && retryAfter > 0) {
return Math.ceil(retryAfter);
}
const pair = (name: string): [number, number] | null => {
const parts = (headers.get(name) ?? "").split(",").map(Number);
return parts.length === 2 && parts.every(Number.isFinite)
? [parts[0]!, parts[1]!]
: null;
};
const dailySpent = (limitName: string, usageName: string): boolean => {
const limit = pair(limitName);
const usage = pair(usageName);
return limit !== null && usage !== null && usage[1] >= limit[1];
};
const nowMs = now.getTime();
const resetAt =
dailySpent("x-ratelimit-limit", "x-ratelimit-usage") ||
dailySpent("x-readratelimit-limit", "x-readratelimit-usage")
? Math.floor(nowMs / DAY_MS) * DAY_MS + DAY_MS
: Math.floor(nowMs / QUARTER_HOUR_MS) * QUARTER_HOUR_MS + QUARTER_HOUR_MS;
return Math.max(1, Math.ceil((resetAt - nowMs) / 1000));
}
/** One page of the athlete's activities (newest first). */
export async function fetchActivities(
accessToken: string,
@@ -312,6 +355,13 @@ export interface StravaWorkoutRow {
maxHeartRate: number | null;
elevationM: number | null;
metadata: Prisma.InputJsonValue;
/**
* True when the row was mapped from the summary alone. The calories, the
* description and (when the summary lacks it) the heart rate exist only on
* the detail read, so an update from such a row must not write their
* absence over what an earlier detail read stored.
*/
summaryOnly?: boolean;
}
function nonNegInt(n: number | null | undefined): number | null {
@@ -414,6 +464,7 @@ export function mapActivity(
summary.total_elevation_gain ?? detail?.total_elevation_gain,
),
metadata,
summaryOnly: detail == null,
};
}
+15 -1
View File
@@ -28,18 +28,32 @@ export function classifyStravaResponse(
}
export class StravaApiError extends IntegrationApiError {
/**
* On a 429: how long Strava's own headers say to wait before asking again
* (`stravaRateLimitRetryAfterSeconds`). Undefined on every other failure.
*/
readonly retryAfterSeconds: number | undefined;
constructor(opts: {
verb: string;
classification: IntegrationClassification;
httpStatus: number | undefined;
reason: string;
upstreamError?: string;
retryAfterSeconds?: number;
}) {
super({ vendor: "Strava", ...opts });
const { retryAfterSeconds, ...rest } = opts;
super({ vendor: "Strava", ...rest });
this.name = "StravaApiError";
this.retryAfterSeconds = retryAfterSeconds;
}
}
/** True for Strava's rate-limit refusal. */
export function isStravaRateLimited(err: unknown): err is StravaApiError {
return err instanceof StravaApiError && err.httpStatus === 429;
}
/**
* True when a caught error is an OAuth `invalid_grant` on the token endpoint —
* the canonical revoked / rotated-away refresh-token signal. Lifts a 400
+116 -24
View File
@@ -21,10 +21,14 @@
* Rate limits: 200 req / 15 min + 2000 / day per app. The walk is page-bounded
* (`walkPages`, which reports a walk cut off at its ceiling instead of
* passing it off as the whole history) and the per-activity detail fetch (for `calories`, absent from the summary)
* is budget-capped; a 429 mid-walk records a transient failure and the next
* cron tick resumes from the cursor.
* is budget-capped. A 429 mid-walk ends the walk where it stands: what was
* read lands, the cursor moves only as far as an oldest-first walk proves, the
* failure is booked transient, and the run reports how long Strava's headers
* say to wait (`retryAfterSeconds`), which the backfill waits out before its
* next run. It used to throw out of the walk and discard everything read.
*/
import { prisma } from "@/lib/db";
import type { Prisma } from "@/generated/prisma/client";
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -50,7 +54,11 @@ import {
getStravaConnection,
storeStravaTokens,
} from "./credentials";
import { StravaApiError, classifyStravaError } from "./response-classifier";
import {
StravaApiError,
classifyStravaError,
isStravaRateLimited,
} from "./response-classifier";
/** Default first-incremental lookback (days) when no cursor exists yet. */
export const STRAVA_SYNC_LOOKBACK_DAYS = 30;
@@ -115,6 +123,8 @@ export interface StravaHistoryRun {
* expect.
*/
oldestStart: Date | null;
/** Set when Strava's rate limit ended the run: seconds until it resets. */
retryAfterSeconds?: number;
}
/**
@@ -203,6 +213,9 @@ async function runStravaSync(
let oldestStart: Date | null = null;
let previousStart: number | null = null;
let newestFirst = true;
// Assigned inside the page callback; the cast keeps the compiler from
// narrowing it to its initial `null` after the walk.
let rateLimited = null as StravaApiError | null;
try {
// Strava pages by number; a short page is the last one. A walk that
@@ -213,29 +226,37 @@ async function runStravaSync(
maxPages,
async (cursor) => {
const page = cursor ?? 1;
const activities = await authed((t) =>
fetchActivities(t, {
after: afterEpoch,
before: beforeEpoch,
page,
perPage: PER_PAGE,
}),
);
let activities: Awaited<ReturnType<typeof fetchActivities>>;
try {
activities = await authed((t) =>
fetchActivities(t, {
after: afterEpoch,
before: beforeEpoch,
page,
perPage: PER_PAGE,
}),
);
} catch (err) {
if (!isStravaRateLimited(err)) throw err;
rateLimited = err;
return { items: [], next: null, interrupted: true };
}
const mapped: StravaWorkoutRow[] = [];
for (const a of activities) {
// Fetch detail only to fill `calories` (absent from the summary),
// and only while the budget allows. When the summary already lacks
// HR we also let the detail top it up. A detail failure is
// non-fatal — map from the summary — but a 429 aborts the whole
// walk (transient).
// non-fatal — map from the summary — but a 429 ends the walk here,
// keeping the activities before this one.
let detail: StravaDetailedActivity | null = null;
if (detailBudget > 0) {
try {
detail = await authed((t) => fetchActivityById(t, a.id));
detailBudget -= 1;
} catch (err) {
if (err instanceof StravaApiError && err.httpStatus === 429) {
throw err;
if (isStravaRateLimited(err)) {
rateLimited = err;
return { items: mapped, next: null, interrupted: true };
}
getEvent()?.addWarning(
`strava: activity detail fetch failed for ${a.id}: ${err}`,
@@ -277,6 +298,12 @@ async function runStravaSync(
throw markSyncFailureRecorded(err);
}
// A walk the rate limit ended is a prefix of the pages in the order Strava
// served them. Oldest first (an incremental walk), every activity up to the
// newest one read is in; newest first, older ones are still missing, and
// moving the cursor would skip them for good.
const cursorSafe = rateLimited === null || !newestFirst;
let imported: number;
try {
imported = await upsertStravaWorkouts(userId, rows);
@@ -284,7 +311,7 @@ async function runStravaSync(
// Advance the incremental cursor to the newest activity start we saw, but
// never move it backwards (a bounded backfill page-walk may not reach the
// very newest activity in one pass).
if (newestStart) {
if (newestStart && cursorSafe) {
await prisma.user.updateMany({
where: {
id: userId,
@@ -301,6 +328,26 @@ async function runStravaSync(
throw markSyncFailureRecorded(err);
}
if (rateLimited) {
// Not finished, so no success; booked transient, the next run goes on.
await recordStravaFailure(userId, rateLimited);
if (imported > 0) invalidateUserMeasurements(userId);
annotate({
action: {
name: "strava.sync.rate_limited",
details: {
imported,
retry_after_seconds: rateLimited.retryAfterSeconds ?? null,
},
},
});
return {
imported,
oldestStart: newestFirst ? oldestStart : null,
retryAfterSeconds: rateLimited.retryAfterSeconds,
};
}
await recordSyncSuccess(userId, "strava");
// Background-sync posture: sweep the workouts cache and mark the analytics
@@ -316,6 +363,42 @@ async function runStravaSync(
return { imported, oldestStart: newestFirst ? oldestStart : null };
}
/**
* The update for a row mapped from the summary alone. Calories, the
* description and (when the summary has none) the heart rate come only from
* the detail read, so their absence here says nothing: the stored values stay.
* A detail read that did run is written as it came, nulls included.
*/
async function summaryOnlyUpdate<
D extends {
totalEnergyKcal: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
metadata: Prisma.InputJsonValue;
},
>(data: D, stored: { metadata: Prisma.JsonValue } | null) {
const { totalEnergyKcal, avgHeartRate, maxHeartRate, metadata, ...rest } =
data;
const storedMeta =
stored?.metadata && typeof stored.metadata === "object"
? (stored.metadata as Record<string, unknown>)
: {};
const description = storedMeta.stravaDescription;
return {
...rest,
...(totalEnergyKcal != null ? { totalEnergyKcal } : {}),
...(avgHeartRate != null ? { avgHeartRate } : {}),
...(maxHeartRate != null ? { maxHeartRate } : {}),
metadata:
typeof description === "string"
? ({
...(metadata as Record<string, unknown>),
stravaDescription: description,
} as Prisma.InputJsonValue)
: metadata,
};
}
/**
* Upsert a batch of mapped Strava activities into `Workout`, keyed on
* `(userId, source = STRAVA, externalId)` so a re-fetch overwrites in place.
@@ -362,15 +445,24 @@ export async function upsertStravaWorkouts(
() => {},
);
} else {
await prisma.workout.update({
where: {
userId_source_externalId: {
userId,
source: "STRAVA",
externalId: r.externalId,
},
const where = {
userId_source_externalId: {
userId,
source: "STRAVA" as const,
externalId: r.externalId,
},
data,
};
await prisma.workout.update({
where,
data: r.summaryOnly
? await summaryOnlyUpdate(
data,
await prisma.workout.findUnique({
where,
select: { metadata: true },
}),
)
: data,
});
}
imported += 1;
@@ -533,6 +533,84 @@ describe("parseSourcePriority — duplicate tolerance (v1.16.11)", () => {
const ladder = resolved.metricPriority.weight;
expect(new Set(ladder).size).toBe(ladder.length);
expect(ladder.slice(0, 2)).toEqual(["MANUAL", "WITHINGS"]);
expect(ladder.length).toBeLessThanOrEqual(8);
// The schema caps a ladder at one entry per source.
expect(ladder.length).toBeLessThanOrEqual(
measurementSourceEnum.options.length,
);
});
});
describe("DEFAULT_SOURCE_PRIORITY — the sources that had no place (v1.43.11)", () => {
const HAND_ENTERED = ["EXTERNAL", "MCP", "TELEGRAM"] as const;
// What Health Connect imports, by ladder (`import/health-connect/mapper.ts`).
const HEALTH_CONNECT_LADDERS = [
"steps",
"activeEnergy",
"walkingRunningDistance",
"bloodPressure",
"bodyFat",
"bodyTemperature",
"hrvRmssd",
"spo2",
"respiratoryRate",
"restingHeartRate",
"vo2Max",
"weight",
] as const;
it("ranks the hand-entered sources right after MANUAL wherever MANUAL ranks", () => {
for (const [key, ladder] of Object.entries(DEFAULT_SOURCE_PRIORITY)) {
const i = (ladder as string[]).indexOf("MANUAL");
if (i === -1) continue;
expect(ladder.slice(i + 1, i + 1 + HAND_ENTERED.length), key).toEqual([
...HAND_ENTERED,
]);
}
});
it("ranks Health Connect right after Apple Health on every ladder it imports for", () => {
for (const key of HEALTH_CONNECT_LADDERS) {
const ladder = DEFAULT_SOURCE_PRIORITY[key] as string[];
expect(ladder[ladder.indexOf("APPLE_HEALTH") + 1], key).toBe(
"HEALTH_CONNECT",
);
}
});
it("leaves the order of the sources the ladders already named alone", () => {
const added = new Set<string>([...HAND_ENTERED, "HEALTH_CONNECT"]);
const without = (key: keyof typeof DEFAULT_SOURCE_PRIORITY) =>
DEFAULT_SOURCE_PRIORITY[key].filter((s) => !added.has(s));
expect(without("weight")).toEqual([
"WITHINGS",
"APPLE_HEALTH",
"MANUAL",
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
]);
expect(without("bloodPressure")).toEqual([
"WITHINGS",
"APPLE_HEALTH",
"MANUAL",
]);
expect(without("spo2")).toEqual([
"WITHINGS",
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"POLAR",
"APPLE_HEALTH",
"MANUAL",
]);
});
it("fits every default ladder within the schema cap", () => {
for (const [key, ladder] of Object.entries(DEFAULT_SOURCE_PRIORITY)) {
expect(ladder.length, key).toBeLessThanOrEqual(
measurementSourceEnum.options.length,
);
}
});
});
+78 -4
View File
@@ -234,6 +234,16 @@ export type DeviceTypePriority = z.infer<typeof deviceTypePrioritySchema>;
* pulse-ox, Thermo). Apple Health is second-hand (HealthKit
* receives the same reading via Withings' Health Mate iOS app).
*/
// v1.43.11 — the sources that had no place on any ladder. A source a ladder
// does not name ranks 90, below everything it does, so a typed weight from a
// Telegram reminder lost its day to a WHOOP strap's estimate, and a Health
// Connect day lost to every other source. Health Connect is the Android
// counterpart of the HealthKit relay and sits right after APPLE_HEALTH on
// the ladders for what it imports; the readings a person enters or bridges
// themselves (EXTERNAL: their own hardware's uploader, MCP: an assistant
// writing on their word, TELEGRAM: a reply to a reminder) sit right after
// MANUAL, in the alphabetical order they already broke ties in. Relative to
// each other the existing sources keep their order.
export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
// v1.12.0 — Fitbit/Pixel rides below Apple Health for cumulative metrics:
// HealthKit still aggregates the broadest device set, and a Fitbit-only
@@ -253,6 +263,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
// still survives.
steps: [
"APPLE_HEALTH",
"HEALTH_CONNECT",
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
@@ -260,24 +271,35 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"POLAR",
"STRAVA",
"MANUAL",
"EXTERNAL",
"MCP",
"TELEGRAM",
],
activeEnergy: [
"APPLE_HEALTH",
"HEALTH_CONNECT",
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"POLAR",
"MANUAL",
"EXTERNAL",
"MCP",
"TELEGRAM",
],
walkingRunningDistance: [
"APPLE_HEALTH",
"HEALTH_CONNECT",
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"POLAR",
"MANUAL",
"EXTERNAL",
"MCP",
"TELEGRAM",
],
flightsClimbed: [
"APPLE_HEALTH",
@@ -285,6 +307,9 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"FITBIT",
"GOOGLE_HEALTH",
"MANUAL",
"EXTERNAL",
"MCP",
"TELEGRAM",
],
// v1.11.0 — WHOOP leads the recovery-input ladders (sleep / HRV / RHR):
// a worn-all-night strap has higher-resolution overnight sampling than
@@ -319,18 +344,31 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"OURA",
"POLAR",
"APPLE_HEALTH",
"HEALTH_CONNECT",
"WITHINGS",
],
// A real scale beats a strap's body-measurement estimate for weight.
weight: [
"WITHINGS",
"APPLE_HEALTH",
"HEALTH_CONNECT",
"MANUAL",
"EXTERNAL",
"MCP",
"TELEGRAM",
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
],
bloodPressure: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
bloodPressure: [
"WITHINGS",
"APPLE_HEALTH",
"HEALTH_CONNECT",
"MANUAL",
"EXTERNAL",
"MCP",
"TELEGRAM",
],
// v1.17.0 — continuous optical-HR wearables (Fitbit / Polar / Oura) rank
// above a hand-typed MANUAL pulse; the device-source order matches the
// rhr / hrv ladders (FITBIT > OURA > POLAR). Withings BPM cuff + the
@@ -343,12 +381,29 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"OURA",
"POLAR",
"MANUAL",
"EXTERNAL",
"MCP",
"TELEGRAM",
],
bodyFat: [
"WITHINGS",
"APPLE_HEALTH",
"HEALTH_CONNECT",
"MANUAL",
"EXTERNAL",
"MCP",
"TELEGRAM",
"FITBIT",
"GOOGLE_HEALTH",
],
bodyFat: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT", "GOOGLE_HEALTH"],
bodyTemperature: [
"WITHINGS",
"APPLE_HEALTH",
"HEALTH_CONNECT",
"MANUAL",
"EXTERNAL",
"MCP",
"TELEGRAM",
"FITBIT",
"GOOGLE_HEALTH",
],
@@ -363,15 +418,23 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"OURA",
"POLAR",
"APPLE_HEALTH",
"HEALTH_CONNECT",
"MANUAL",
"EXTERNAL",
"MCP",
"TELEGRAM",
],
vo2Max: [
"WITHINGS",
"APPLE_HEALTH",
"HEALTH_CONNECT",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"MANUAL",
"EXTERNAL",
"MCP",
"TELEGRAM",
],
// v1.11.0 — new WHOOP-overlapping keys. ScanWatch dermal reading is the
// primary skin-temperature sensor; WHOOP's strap is second, Fitbit third,
@@ -386,6 +449,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"OURA",
"POLAR",
"APPLE_HEALTH",
"HEALTH_CONNECT",
"WITHINGS",
],
// v1.11.0 — native-vs-derived recovery. WHOOP's device-native Recovery
@@ -404,8 +468,18 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
// on now. Mirrors the recovery ladder's native-above-computed ordering.
sleepDebt: ["WHOOP", "OURA", "POLAR", "COMPUTED"],
// v1.42 (#1110) — the straps and rings that measure RMSSD overnight lead;
// the HealthKit relay of a third-party reading is the fallback.
hrvRmssd: ["WHOOP", "OURA", "POLAR", "APPLE_HEALTH"],
// the HealthKit relay of a third-party reading is the fallback. Fitbit and
// Google Health report RMSSD too and take the places they hold on the SDNN
// ladder they used to be filed under.
hrvRmssd: [
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"POLAR",
"APPLE_HEALTH",
"HEALTH_CONNECT",
],
};
/**
@@ -0,0 +1,85 @@
/**
* The v2 recovery webhook carries the id of the associated SLEEP, and WHOOP
* reads a recovery only through its cycle. The targeted refresh must resolve
* sleep -> cycle -> recovery; passing the sleep UUID straight into the cycle
* route answered 404 on every recovery webhook and booked a sync failure.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fetchSleepById, fetchRecoveryByCycleId, upsertMock, fetchErrorMock } =
vi.hoisted(() => ({
fetchSleepById: vi.fn(),
fetchRecoveryByCycleId: vi.fn(),
upsertMock: vi.fn(async (_u: string, rows: unknown[]) => rows.length),
fetchErrorMock: vi.fn(async () => 0),
}));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("../sync-core", async (importOriginal) => ({
...(await importOriginal<typeof import("../sync-core")>()),
getValidToken: vi.fn(async () => ({ accessToken: "tok" })),
upsertWhoopMeasurements: upsertMock,
handleCollectionFetchError: fetchErrorMock,
}));
vi.mock("../client", async (importOriginal) => ({
...(await importOriginal<typeof import("../client")>()),
fetchSleepById,
fetchRecoveryByCycleId,
}));
import { syncWhoopRecoveryById } from "../sync-recovery";
beforeEach(() => {
vi.clearAllMocks();
});
describe("syncWhoopRecoveryById", () => {
it("reads the sleep for its cycle id, then the recovery through the cycle", async () => {
fetchSleepById.mockResolvedValue({ id: "sleep-uuid", cycle_id: 991 });
fetchRecoveryByCycleId.mockResolvedValue({
cycle_id: 991,
sleep_id: "sleep-uuid",
user_id: 42,
created_at: "2026-06-01T07:00:00.000Z",
updated_at: "2026-06-01T07:05:00.000Z",
score_state: "SCORED",
score: {
user_calibrating: false,
recovery_score: 70,
resting_heart_rate: 52,
hrv_rmssd_milli: 61,
},
});
const imported = await syncWhoopRecoveryById("user-1", "sleep-uuid");
expect(fetchSleepById).toHaveBeenCalledWith("tok", "sleep-uuid");
expect(fetchRecoveryByCycleId).toHaveBeenCalledWith("tok", "991");
expect(fetchErrorMock).not.toHaveBeenCalled();
expect(imported).toBe(3);
const rows = upsertMock.mock.calls[0][1] as Array<{ externalId: string }>;
expect(rows.map((r) => r.externalId)).toEqual([
"sleep-uuid:recovery",
"sleep-uuid:hrv_rmssd",
"sleep-uuid:rhr",
]);
});
it("imports nothing when the sleep carries no cycle id", async () => {
fetchSleepById.mockResolvedValue({ id: "sleep-uuid" });
expect(await syncWhoopRecoveryById("user-1", "sleep-uuid")).toBe(0);
expect(fetchRecoveryByCycleId).not.toHaveBeenCalled();
expect(upsertMock).not.toHaveBeenCalled();
});
it("routes a failed sleep read through the shared fetch-error path", async () => {
const err = new Error("boom");
fetchSleepById.mockRejectedValue(err);
await syncWhoopRecoveryById("user-1", "sleep-uuid");
expect(fetchErrorMock).toHaveBeenCalledWith("recovery", "user-1", err);
});
});
@@ -54,7 +54,18 @@ vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ setAuth: vi.fn(), addWarning: vi.fn() }),
}));
import { processWhoopNotification } from "../webhook-handler";
import {
deletedResourceExternalIdFilter,
processWhoopNotification,
} from "../webhook-handler";
import {
mapRecovery,
mapSleep,
WHOOP_RECOVERY_FIELD_TAGS,
type WhoopRecovery,
type WhoopSleep,
} from "../client";
import type { Prisma } from "@/generated/prisma/client";
beforeEach(() => {
vi.clearAllMocks();
@@ -79,7 +90,7 @@ describe("processWhoopNotification — fetch-by-id enqueue", () => {
});
});
it("carries a numeric resource id (cycle-based recovery) as a string", async () => {
it("carries a numeric resource id as a string", async () => {
await processWhoopNotification({
user_id: 42,
id: 9981,
@@ -148,3 +159,107 @@ describe("processWhoopNotification — fetch-by-id enqueue", () => {
);
});
});
// One night as WHOOP v2 models it: a sleep and the recovery scored from it.
// Both key their rows off the sleep UUID, which is also the id the recovery
// webhook carries.
const SLEEP: WhoopSleep = {
id: "sleep-uuid",
cycle_id: 991,
user_id: 42,
created_at: "2026-06-01T05:00:00.000Z",
updated_at: "2026-06-01T07:00:00.000Z",
start: "2026-05-31T23:00:00.000Z",
end: "2026-06-01T07:00:00.000Z",
nap: false,
score_state: "SCORED",
score: {
stage_summary: {
total_in_bed_time_milli: 8 * 3_600_000,
total_awake_time_milli: 30 * 60_000,
total_light_sleep_time_milli: 4 * 3_600_000,
total_slow_wave_sleep_time_milli: 90 * 60_000,
total_rem_sleep_time_milli: 2 * 3_600_000,
disturbance_count: 4,
},
sleep_needed: {
baseline_milli: 8 * 3_600_000,
need_from_sleep_debt_milli: 0,
need_from_recent_strain_milli: 0,
need_from_recent_nap_milli: 0,
},
respiratory_rate: 15,
sleep_performance_percentage: 90,
sleep_consistency_percentage: 80,
sleep_efficiency_percentage: 92,
},
};
const RECOVERY: WhoopRecovery = {
cycle_id: 991,
sleep_id: "sleep-uuid",
user_id: 42,
created_at: "2026-06-01T07:00:00.000Z",
updated_at: "2026-06-01T07:05:00.000Z",
score_state: "SCORED",
score: {
user_calibrating: false,
recovery_score: 70,
resting_heart_rate: 52,
hrv_rmssd_milli: 61,
spo2_percentage: 96,
skin_temp_celsius: 33.4,
},
};
const SLEEP_IDS = mapSleep(SLEEP).map(
(m) => m.externalId ?? `${SLEEP.id}:${m.fieldTag}`,
);
const RECOVERY_IDS = mapRecovery(RECOVERY).map(
(m) => `${RECOVERY.sleep_id}:${m.fieldTag}`,
);
/** The subset of Prisma's StringFilter semantics the filter uses. */
function matches(filter: Prisma.StringFilter, id: string): boolean {
if (filter.in && !(filter.in as string[]).includes(id)) return false;
if (filter.notIn && (filter.notIn as string[]).includes(id)) return false;
if (filter.startsWith && !id.startsWith(filter.startsWith as string)) {
return false;
}
return true;
}
describe("deletedResourceExternalIdFilter — one night, two resources", () => {
it("pins the recovery field-tag list to what the mapper emits", () => {
expect(new Set(mapRecovery(RECOVERY).map((m) => m.fieldTag))).toEqual(
new Set(WHOOP_RECOVERY_FIELD_TAGS),
);
});
it("a recovery deletion removes the recovery and leaves the night's sleep", () => {
const filter = deletedResourceExternalIdFilter("recovery", "sleep-uuid");
expect(SLEEP_IDS.length).toBeGreaterThan(5);
expect(RECOVERY_IDS.filter((id) => matches(filter, id))).toEqual(
RECOVERY_IDS,
);
expect(SLEEP_IDS.filter((id) => matches(filter, id))).toEqual([]);
});
it("a sleep deletion removes the sleep and leaves its recovery", () => {
const filter = deletedResourceExternalIdFilter("sleep", "sleep-uuid");
expect(SLEEP_IDS.filter((id) => matches(filter, id))).toEqual(SLEEP_IDS);
expect(RECOVERY_IDS.filter((id) => matches(filter, id))).toEqual([]);
});
it("a recovery.deleted webhook passes the exact-id match to both reads", async () => {
updateMany.mockResolvedValue({ count: 5 });
await processWhoopNotification({
user_id: 42,
id: "sleep-uuid",
type: "recovery.deleted",
});
const expected = {
in: WHOOP_RECOVERY_FIELD_TAGS.map((t) => `sleep-uuid:${t}`),
};
expect(findMany.mock.calls[0][0].where.externalId).toEqual(expected);
expect(updateMany.mock.calls[0][0].where.externalId).toEqual(expected);
});
});
+25 -2
View File
@@ -180,6 +180,12 @@ export interface WhoopRecovery {
/** Sleep activity record. */
export interface WhoopSleep {
id: string;
/**
* The cycle this sleep belongs to. A v2 recovery is read through its cycle,
* and the recovery webhook carries the SLEEP id, so the targeted recovery
* refresh resolves sleep -> cycle -> recovery through this field.
*/
cycle_id?: number;
user_id: number;
created_at: string;
updated_at: string;
@@ -449,8 +455,10 @@ export function fetchCycleById(
/**
* WHOOP v2 has no recovery-by-recovery-id route — a recovery is read through
* its cycle (`GET /v2/cycle/{cycleId}/recovery`). The recovery webhook carries
* the cycle id, so the targeted refresh resolves the recovery from there.
* its cycle (`GET /v2/cycle/{cycleId}/recovery`). The v2 recovery webhook
* carries the id of the associated SLEEP, not the cycle, so the targeted
* refresh first reads the sleep for its `cycle_id` (see
* `syncWhoopRecoveryById`).
*/
export function fetchRecoveryByCycleId(
accessToken: string,
@@ -523,6 +531,21 @@ export interface MappedMeasurement {
reconstructed?: boolean;
}
/**
* Every field-tag `mapRecovery` can emit. A recovery's rows are keyed
* `<sleep_id>:<fieldTag>` and share the `<sleep_id>:` prefix with the rows of
* the sleep it belongs to, so a recovery deletion must name these exact ids
* rather than match on the prefix (which would take the night's sleep rows
* with it). Pinned against the mapper by a test.
*/
export const WHOOP_RECOVERY_FIELD_TAGS = [
"recovery",
"hrv_rmssd",
"rhr",
"spo2",
"skin_temp",
] as const;
/**
* Map one WHOOP recovery record into Measurement readings. An unscored record
* (`score === null`) yields nothing. `recovery.score.recovery_score` →
+23 -10
View File
@@ -9,7 +9,12 @@
* sleep UUID (v2 recovery carries `sleep_id`, not a stable recovery id) so the
* externalId is stable across re-scores.
*/
import { fetchRecoveries, fetchRecoveryByCycleId, mapRecovery } from "./client";
import {
fetchRecoveries,
fetchRecoveryByCycleId,
fetchSleepById,
mapRecovery,
} from "./client";
import {
getValidToken,
incrementalStart,
@@ -71,24 +76,32 @@ export async function syncUserRecovery(
}
/**
* Webhook-driven targeted refresh: resolve ONE recovery by its cycle id and
* upsert its readings, instead of re-walking the whole collection. WHOOP v2
* reads a recovery through its cycle (`/v2/cycle/{cycleId}/recovery`), and the
* recovery webhook carries the cycle id. An unscored / since-deleted record
* yields nothing. Does NOT advance the resource cursor — a single-id refresh
* proves nothing about records between the cursor and now, so the cron/overlap
* path stays responsible for moving the cursor.
* Webhook-driven targeted refresh: resolve ONE recovery and upsert its
* readings, instead of re-walking the whole collection. The v2 recovery
* webhook carries the id of the associated SLEEP (a UUID), but WHOOP reads a
* recovery only through its cycle (`/v2/cycle/{cycleId}/recovery`), so the
* sleep is read first for its `cycle_id`. Passing the sleep UUID as a cycle id
* answered 404 on every recovery webhook and booked each one as a sync
* failure. An unscored / since-deleted record yields nothing. Does NOT advance
* the resource cursor — a single-id refresh proves nothing about records
* between the cursor and now, so the cron/overlap path stays responsible for
* moving the cursor.
*/
export async function syncWhoopRecoveryById(
userId: string,
cycleId: string,
sleepId: string,
): Promise<number> {
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
let record: Awaited<ReturnType<typeof fetchRecoveryByCycleId>>;
try {
record = await fetchRecoveryByCycleId(tokenInfo.accessToken, cycleId);
const sleep = await fetchSleepById(tokenInfo.accessToken, sleepId);
if (sleep.cycle_id == null) return 0;
record = await fetchRecoveryByCycleId(
tokenInfo.accessToken,
String(sleep.cycle_id),
);
} catch (err) {
return handleCollectionFetchError("recovery", userId, err);
}
+32 -6
View File
@@ -32,7 +32,8 @@ import { getClientIp } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import type { MeasurementType } from "@/generated/prisma/client";
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
import { WHOOP_RECOVERY_FIELD_TAGS } from "./client";
/**
* Maximum age of a webhook timestamp before it is treated as a replay. Five
@@ -119,6 +120,31 @@ export function verifyWhoopSignature(args: {
return timingSafeEqual(a, b);
}
/**
* The `externalId` match for the rows a `<resource>.deleted` event removes.
*
* Rows are keyed `<resource-id>:<field-tag>`, but the v2 recovery webhook
* carries the id of the associated SLEEP, and a recovery's rows are keyed off
* that same sleep id (`<sleep_id>:hrv_rmssd`, ...). The sleep's own rows share
* the prefix (`<sleep_id>:sleep_eff`, `<sleep_id>:seg:REM`, ...). A prefix
* match on a recovery deletion therefore took the whole night's sleep with it,
* and a sleep deletion took the recovery. Recovery names its exact ids; sleep
* keeps the prefix minus those ids; every other resource keeps the prefix.
*/
export function deletedResourceExternalIdFilter(
resource: string | undefined,
resourceId: string,
): Prisma.StringFilter {
const recoveryIds = WHOOP_RECOVERY_FIELD_TAGS.map(
(tag) => `${resourceId}:${tag}`,
);
if (resource === "recovery") return { in: recoveryIds };
if (resource === "sleep") {
return { startsWith: `${resourceId}:`, notIn: recoveryIds };
}
return { startsWith: `${resourceId}:` };
}
interface WhoopWebhookBody {
user_id?: number | string;
id?: number | string;
@@ -160,11 +186,11 @@ export async function processWhoopNotification(
const queue = WHOOP_RESOURCE_QUEUE[resource ?? ""];
if (verb === "deleted") {
// Soft-delete every matching row for this user + WHOOP resource id.
// `externalId` is `<whoop-resource-uuid>:<field-tag>` so a `startsWith`
// match catches every measurement derived from the deleted resource.
// Soft-delete every row derived from the deleted resource for this user.
// See `deletedResourceExternalIdFilter` for why the match is per resource.
if (resourceId) {
const now = new Date();
const externalId = deletedResourceExternalIdFilter(resource, resourceId);
// Collect the affected `(type, measuredAt)` pairs BEFORE the
// soft-delete — the rollup tail below needs to know which
// `(type, day)` buckets lose rows, and after the updateMany the
@@ -173,7 +199,7 @@ export async function processWhoopNotification(
where: {
userId: connection.userId,
source: "WHOOP",
externalId: { startsWith: `${resourceId}:` },
externalId,
deletedAt: null,
},
select: { type: true, measuredAt: true },
@@ -182,7 +208,7 @@ export async function processWhoopNotification(
where: {
userId: connection.userId,
source: "WHOOP",
externalId: { startsWith: `${resourceId}:` },
externalId,
deletedAt: null,
},
data: { deletedAt: now, syncVersion: { increment: 1 } },
@@ -101,6 +101,7 @@ interface FakeActivity {
steps?: number;
distance?: number;
calories?: number;
brand?: number;
}
function installFetchMock(entries: FakeActivity[]) {
@@ -414,6 +415,28 @@ describe("syncUserActivity — field mapping + idempotency", () => {
expect(types).not.toContain("WALKING_RUNNING_DISTANCE");
});
it("skips a day Health Mate holds from another app (brand 18)", async () => {
// An Apple Health or Google Fit day written into Health Mate comes back
// from Getactivity as brand 18 ("external"). It reaches HealthLog from
// its own integration; under WITHINGS it was a second copy.
installFetchMock([
{ date: "2026-05-12", steps: 8000, calories: 380, brand: 1 },
{ date: "2026-05-13", steps: 9100, calories: 410, brand: 18 },
]);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({
count: 2,
} as never);
await syncUserActivity("user-1");
const rows = createManyRows();
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.value)).toEqual(
expect.not.arrayContaining([9100, 410]),
);
});
it("ingests a 0-step rest day rather than dropping it", async () => {
// Withings reports 0 steps for a day a user spent in bed — that's
// a valid datapoint, not a missing one. The mapper must not skip
+247 -56
View File
@@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
@@ -105,18 +106,71 @@ interface FakeSegment {
id?: number;
}
function installFetchMock(segments: FakeSegment[]) {
const fetchMock = vi.fn(async () => ({
status: 200,
json: async () => ({
status: 0,
body: { series: segments },
}),
}));
type FakeSummary = {
id?: number;
startdate: number;
enddate: number;
modified?: number;
data?: Record<string, number | null>;
};
/** The night list Withings would derive from these segments, one per session. */
function nightsOf(segments: FakeSegment[]): FakeSummary[] {
const bySession = new Map<string, FakeSummary>();
for (const seg of segments) {
const key = String(seg.id ?? "no-id");
const night = bySession.get(key);
bySession.set(
key,
night
? {
...night,
startdate: Math.min(night.startdate, seg.startdate),
enddate: Math.max(night.enddate, seg.enddate),
}
: { id: seg.id, startdate: seg.startdate, enddate: seg.enddate },
);
}
return [...bySession.values()];
}
/**
* Answer the two Sleep v2 actions the way Withings does: `getsummary` lists
* the nights, `get` returns the segments inside the requested span, and no
* more than its first 24 hours.
*/
function installSleepApi(segments: FakeSegment[], summaries: FakeSummary[]) {
const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
const params = new URLSearchParams(init.body);
if (params.get("action") === "getsummary") {
return {
status: 200,
json: async () => ({ status: 0, body: { series: summaries } }),
};
}
// Sleep v2 Get: "only the first 24h after startdate will be returned".
const from = Number(params.get("startdate"));
const to = Math.min(Number(params.get("enddate")), from + 24 * 3600);
return {
status: 200,
json: async () => ({
status: 0,
body: {
series: segments.filter(
(seg) => seg.startdate >= from && seg.enddate <= to,
),
},
}),
};
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function installFetchMock(segments: FakeSegment[]) {
return installSleepApi(segments, nightsOf(segments));
}
beforeEach(() => {
vi.resetAllMocks();
transactionMock.mockImplementation(async (run: (tx: unknown) => unknown) =>
@@ -144,6 +198,8 @@ beforeEach(() => {
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics,user.activity",
} as never);
// No stage row stored yet: every listed night is one to read.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
@@ -158,21 +214,9 @@ afterEach(() => {
*/
function installSegmentThenSummaryFetch(
segments: FakeSegment[],
summaries: Array<{
id?: number;
startdate: number;
enddate: number;
data?: Record<string, number | null>;
}>,
summaries: FakeSummary[],
) {
let call = 0;
const fetchMock = vi.fn(async () => {
const body = call === 0 ? { series: segments } : { series: summaries };
call++;
return { status: 200, json: async () => ({ status: 0, body }) };
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
return installSleepApi(segments, summaries);
}
describe("mapWithingsSleepSummary", () => {
@@ -559,44 +603,50 @@ describe("syncUserSleep — segment writes + idempotency", () => {
expect(byType.get("OXYGEN_SATURATION")?.value).toBe(95);
});
it("keeps stage segments when the summary fetch fails (v1.18.10 P0)", async () => {
let call = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => {
const isSummary = call > 0;
call++;
return {
status: 200,
json: async () =>
isSummary
? { status: 293 } // summary errors
: {
status: 0,
body: {
series: [
{
startdate: 1715000000,
enddate: 1715003600,
state: 2,
id: 1,
},
],
},
it("reads the last 24 hours and books the leg failed when the summary fails", async () => {
const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
const params = new URLSearchParams(init.body);
return {
status: 200,
json: async () =>
params.get("action") === "getsummary"
? { status: 293 } // summary errors
: {
status: 0,
body: {
series: [
{
startdate: 1715000000,
enddate: 1715003600,
state: 2,
id: 1,
},
],
},
};
}),
);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
},
};
});
vi.stubGlobal("fetch", fetchMock);
const imported = await syncUserSleep("user-1");
// The one stage segment is still written; the summary failure is swallowed.
// Without the night list one slice of the last 24 hours is read, so
// last night still lands; it is not swept, since a slice can cut a night.
expect(imported).toBe(1);
expect(reconcileMock).toHaveBeenCalledTimes(1);
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
leg: "sleep",
});
const stageCalls = fetchMock.mock.calls
.map(([, init]) => new URLSearchParams(init.body))
.filter((p) => p.get("action") === "get");
expect(stageCalls).toHaveLength(1);
expect(
Number(stageCalls[0]!.get("enddate")) -
Number(stageCalls[0]!.get("startdate")),
).toBe(24 * 3600);
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
// The failure is the leg's, not a warning beside a green leg.
expect(recordSyncSuccess).not.toHaveBeenCalled();
expect(recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({ integration: "withings", leg: "sleep" }),
);
});
it("tolerates a ScanWatch night with no REM segment (no all-zeros row)", async () => {
@@ -848,3 +898,144 @@ describe("syncUserSleep — Withings API plan (status 277)", () => {
});
});
});
describe("syncUserSleep — one stage read per night (Sleep v2 Get answers 24 h)", () => {
const NOW = new Date("2026-10-21T09:00:00.000Z");
const nowUnix = Math.floor(NOW.getTime() / 1000);
const DAY = 24 * 3600;
/** Night `n` days back: 22:00 to 06:00, one CORE and one DEEP segment. */
function night(n: number, id: number) {
const end = nowUnix - 3 * 3600 - n * DAY; // 06:00 that morning
const startdate = end - 8 * 3600;
return {
summary: { id, startdate, enddate: end },
segments: [
{ startdate, enddate: startdate + 4 * 3600, state: 1, id },
{ startdate: startdate + 4 * 3600, enddate: end, state: 2, id },
],
};
}
function stageReads(fetchMock: ReturnType<typeof installSleepApi>) {
return fetchMock.mock.calls
.map(([, init]) => new URLSearchParams(init.body))
.filter((p) => p.get("action") === "get")
.map((p) => [Number(p.get("startdate")), Number(p.get("enddate"))]);
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it("reads every listed night with its own span and lands all of them", async () => {
// Before, one read over the 30-day window answered only its first 24
// hours: last night's stages came weeks late.
const nights = [0, 3, 9].map((n, i) => night(n, 100 + i));
const fetchMock = installSleepApi(
nights.flatMap((x) => x.segments),
nights.map((x) => x.summary),
);
const imported = await syncUserSleep("user-1");
expect(imported).toBe(6);
const bySpan = (a: number[], b: number[]) => a[0]! - b[0]!;
expect([...stageReads(fetchMock)].sort(bySpan)).toEqual(
nights.map((x) => [x.summary.startdate, x.summary.enddate]).sort(bySpan),
);
for (const [from, to] of stageReads(fetchMock)) {
expect(to - from).toBeLessThanOrEqual(DAY);
}
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
leg: "sleep",
});
});
it("re-reads the recent nights every run and skips older nights already stored", async () => {
const recent = night(0, 1);
const stored = night(5, 2);
const missing = night(6, 3);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ measuredAt: new Date(stored.summary.enddate * 1000) },
] as never);
// The recent night is stored too; it is read again for a re-score.
const fetchMock = installSleepApi(
[...recent.segments, ...stored.segments, ...missing.segments],
[recent.summary, stored.summary, missing.summary],
);
await syncUserSleep("user-1");
expect(stageReads(fetchMock)).toEqual([
[recent.summary.startdate, recent.summary.enddate],
[missing.summary.startdate, missing.summary.enddate],
]);
});
it("re-reads an older stored night Withings changed in the last 48 hours", async () => {
const rescored = night(6, 4);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ measuredAt: new Date(rescored.summary.enddate * 1000) },
] as never);
const fetchMock = installSleepApi(rescored.segments, [
{ ...rescored.summary, modified: nowUnix - 3600 } as FakeSummary,
]);
await syncUserSleep("user-1");
expect(stageReads(fetchMock)).toEqual([
[rescored.summary.startdate, rescored.summary.enddate],
]);
});
it("reads at most a run's budget of missing nights, rotating which", async () => {
const nights = Array.from({ length: 12 }, (_, i) => night(i + 3, i + 1));
const fetchMock = installSleepApi(
nights.flatMap((x) => x.segments),
nights.map((x) => x.summary),
);
await syncUserSleep("user-1");
const first = stageReads(fetchMock);
expect(first).toHaveLength(8);
fetchMock.mockClear();
vi.setSystemTime(new Date(NOW.getTime() + 3600_000));
await syncUserSleep("user-1");
const second = stageReads(fetchMock);
expect(second).toHaveLength(8);
expect(second).not.toEqual(first);
});
it("reads every night of the window on a full sync", async () => {
const nights = Array.from({ length: 12 }, (_, i) => night(i + 3, i + 1));
const fetchMock = installSleepApi(
nights.flatMap((x) => x.segments),
nights.map((x) => x.summary),
);
await syncUserSleep("user-1", { fullSync: true });
expect(stageReads(fetchMock)).toHaveLength(12);
});
it("caps a night longer than a day at the 24 hours one read can return", async () => {
const startdate = nowUnix - 30 * 3600;
const enddate = nowUnix - 2 * 3600; // 28 hours in bed
const fetchMock = installSleepApi(
[{ startdate, enddate: startdate + 3600, state: 1, id: 5 }],
[{ id: 5, startdate, enddate }],
);
await syncUserSleep("user-1");
expect(stageReads(fetchMock)).toEqual([[startdate, startdate + DAY]]);
// A capped read did not see the whole night, so the night is not swept.
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
});
});
+26
View File
@@ -82,6 +82,9 @@ const WITHINGS_MEASURE_URL = "https://wbsapi.withings.net/v2/measure";
*/
const ACTIVITY_BACKFILL_DAYS = 30;
/** `brand` of a Getactivity day that came from outside Withings. */
const WITHINGS_EXTERNAL_BRAND = 18;
/**
* Withings `getactivity` response shape — one entry per calendar day.
* Only the fields we consume are typed; the response carries 15+ more
@@ -95,6 +98,13 @@ export interface WithingsActivityEntry {
steps?: number;
distance?: number; // metres
calories?: number; // active kcal (NOT totalcalories)
/**
* Where the day came from: 1 for a Withings device or the Health Mate
* app's own tracker, 18 for "an external way" (Getactivity: "Value is 1
* for Withings and 18 for external"), i.e. data another app wrote into
* Health Mate, such as an Apple Health or Google Fit day.
*/
brand?: number;
// The fields below are not ingested today (see file header):
elevation?: number;
totalcalories?: number;
@@ -357,8 +367,18 @@ export async function syncUserActivity(
externalId: string;
}
const collected: PlannedRow[] = [];
let external = 0;
for (const entry of entries) {
if (!entry.date) continue;
// A day Health Mate holds from another app is that app's data, which
// reaches HealthLog from its own integration (Apple Health, Health
// Connect, Google Health). Imported under WITHINGS it was a second copy
// under the wrong name, and on a day that copy was the only Withings row
// it could outrank a native source further down the ladder.
if (entry.brand === WITHINGS_EXTERNAL_BRAND) {
external++;
continue;
}
// Local noon of the date in the user's zone: the instant reads the same
// calendar day there. Noon UTC read as the next day from UTC+13 on
// (Auckland in summer, Tonga, Samoa), so every daily total landed a day
@@ -380,6 +400,12 @@ export async function syncUserActivity(
}
}
if (external > 0) {
getEvent()?.addWarning(
`withings activity: skipped ${external} day(s) Health Mate holds from another app`,
);
}
// A daily-activity total the app's own band calls impossible (a step count
// past 200 000, a negative distance) is a provider artefact, not a day. Drop
// it before the plan is read against the table, so it can neither insert a
+236 -92
View File
@@ -122,6 +122,8 @@ export interface WithingsSleepSummary {
id?: number;
startdate: number;
enddate: number;
/** When Withings last changed the night (unix seconds), e.g. a re-score. */
modified?: number;
data?: {
hr_average?: number | null;
rr_average?: number | null;
@@ -385,56 +387,145 @@ export async function syncUserSleep(
return 0;
}
void opts.fullSync;
const now = new Date();
const start = new Date(
now.getTime() - SLEEP_BACKFILL_DAYS * 24 * 60 * 60 * 1000,
);
const startUnix = Math.floor(start.getTime() / 1000);
const endUnix = Math.floor(now.getTime() / 1000);
let segments: WithingsSleepSegment[];
// The nights come first: `action=getsummary` lists every night of the
// window (and carries its vitals), and the stage timeline is then read per
// night. `action=get` answers only the first 24 hours after its
// `startdate` ("If your input startdate and enddate are separated by more
// than 24h, only the first 24h after startdate will be returned", Sleep v2
// Get); asked for the whole window it returned the night a month back and
// nothing since, so every night's stages arrived when the sliding window
// reached it, weeks late.
//
// Withings API plans (2026-10-12): the sleep score, the overnight HRV and
// the respiration rate are Advanced Biomarkers, and one of them in
// `data_fields` has the whole summary refused with 277. Ask only for what
// the plan allows, remember what it refused, and re-probe weekly.
let summaries: WithingsSleepSummary[] = [];
let summaryFailure: unknown = null;
let nightsKnown = false;
try {
segments = await fetchWithingsSleep(
tokenInfo.accessToken,
startUnix,
endUnix,
);
} catch (err) {
// The stage timeline is a free-plan biomarker, so a plan refusal here is
// not expected. If Withings ever moves it, the leg reads nothing rather
// than failing: a 277 must not climb the strike ladder and park the
// measures, activity and ECG legs along with it.
if (isPlanGatedError(err)) {
annotate({ meta: { withings_sleep_segments_skipped: "api_plan" } });
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_SLEEP });
return 0;
}
// v1.4.43 — typed-classification path (see sync-activity.ts
// for the full rationale). The thrown `WithingsApiError` carries
// the classification verdict; `recordWithingsSyncFailure` routes
// it through `classifyError`, which falls back to the legacy regex
// for un-prototyped errors (pg-boss JSON round-trip).
//
// BL-P3-2 defence-in-depth — symmetric to sync-activity. A 403 on
// this scope-gated endpoint always means scope-missing or
// token-revoked; force `reauth_required` so the 3-strike alert
// still pages on the unexpected case.
const withingsStatus =
err instanceof WithingsApiError ? err.withingsStatus : undefined;
if (withingsStatus === 403) {
await recordSyncFailure({
userId,
integration: "withings",
kind: "reauth_required",
message: err instanceof Error ? err.message : String(err),
errorCode: "403",
leg: WITHINGS_LEG_SLEEP,
const planRow = await prisma.withingsConnection.findUnique({
where: { userId },
select: { apiPlanState: true },
});
const sleepPlan = parseWithingsApiPlanState(
planRow?.apiPlanState,
).sleepSummary;
const reprobed = isPlanReprobeDue(sleepPlan, now);
const skip = planSkipFor(sleepPlan, now);
const plan = await requestWithinPlan({
keys: SLEEP_SUMMARY_DATA_FIELDS.filter((f) => !skip.includes(f)),
knownGated: WITHINGS_ADVANCED_SLEEP_FIELDS,
fetch: (fields) =>
fetchWithingsSleepSummary(
tokenInfo.accessToken,
ymdUtc(start),
ymdUtc(now),
fields,
),
});
summaries = plan.results;
nightsKnown = plan.acceptedAny;
if (
shouldPersistPlanLeg({
gated: plan.gated,
reprobed,
acceptedAny: plan.acceptedAny,
})
) {
await persistWithingsPlanLeg(userId, "sleepSummary", {
unavailable: [...skip, ...plan.unavailable],
checkedAt: planCheckedAtFor(sleepPlan, reprobed, now),
});
}
} catch (err) {
// Booked as this leg's failure once the stages are written: the summary
// carries the vitals and names the nights, and a run without it is not
// a healthy one. It used to be a warning, and the leg read green.
summaryFailure = err;
getEvent()?.addWarning(
`withings sleep: summary fetch failed for ${userId}: ${err}`,
);
}
// Without the night list (the summary failed, or the plan refused every
// field it was asked for) the last 24 hours are read as one slice, so last
// night still lands. Such a slice can cut a night, so it is never swept.
const windows: StageWindow[] = nightsKnown
? await stageWindowsToFetch(userId, summaries, now, {
all: opts.fullSync === true,
})
: [
{
startdate: endUnix - DAY_SECONDS,
enddate: endUnix,
wholeNight: false,
},
];
const segments: WithingsSleepSegment[] = [];
const wholeNightSessions = new Set<number>();
const seenSegments = new Set<string>();
for (const window of windows) {
let series: WithingsSleepSegment[];
try {
series = await fetchWithingsSleep(
tokenInfo.accessToken,
window.startdate,
window.enddate,
);
} catch (err) {
// The stage timeline is a free-plan biomarker, so a plan refusal here
// is not expected. If Withings ever moves it, the leg reads no stages
// rather than failing: a 277 must not climb the strike ladder and park
// the measures, activity and ECG legs along with it.
if (isPlanGatedError(err)) {
annotate({ meta: { withings_sleep_segments_skipped: "api_plan" } });
break;
}
// v1.4.43 — typed-classification path (see sync-activity.ts
// for the full rationale). The thrown `WithingsApiError` carries
// the classification verdict; `recordWithingsSyncFailure` routes
// it through `classifyError`, which falls back to the legacy regex
// for un-prototyped errors (pg-boss JSON round-trip).
//
// BL-P3-2 defence-in-depth — symmetric to sync-activity. A 403 on
// this scope-gated endpoint always means scope-missing or
// token-revoked; force `reauth_required` so the 3-strike alert
// still pages on the unexpected case.
const withingsStatus =
err instanceof WithingsApiError ? err.withingsStatus : undefined;
if (withingsStatus === 403) {
await recordSyncFailure({
userId,
integration: "withings",
kind: "reauth_required",
message: err instanceof Error ? err.message : String(err),
errorCode: "403",
leg: WITHINGS_LEG_SLEEP,
});
throw err;
}
await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_SLEEP);
throw err;
}
await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_SLEEP);
throw err;
for (const segment of series) {
// Two windows can return the same segment (a night longer than its
// 24-hour slice, a nap inside a night); it is written once.
const key = `${segment.id ?? "no-id"}:${segment.startdate}`;
if (seenSegments.has(key)) continue;
seenSegments.add(key);
segments.push(segment);
if (window.wholeNight && typeof segment.id === "number") {
wholeNightSessions.add(segment.id);
}
}
}
let imported = 0;
@@ -528,63 +619,19 @@ export async function syncUserSleep(
// running-index row for re-fetched sessions. Sessions absent from this fetch
// are never touched; a sweep failure never fails the sync (the session stays
// inside the rolling 30-day window, so the hourly cron retries it).
const sweeps: SleepSegmentSweep[] = [...freshBySession].map(
([sessionId, keepIds]) => ({
const sweeps: SleepSegmentSweep[] = [...freshBySession]
// Only a session read across its whole night is swept: a 24-hour slice
// can cut one, and sweeping it would retire the part outside the slice.
.filter(([sessionId]) => wholeNightSessions.has(sessionId))
.map(([sessionId, keepIds]) => ({
prefix: `withings:sleep:${userId}:${sessionId}:`,
keepIds,
}),
);
}));
await sweepStaleSleepSegments(userId, "WITHINGS", sweeps);
// v1.18.10 — nightly sleep vitals (avg HR / respiratory rate / SDNN HRV
// / avg SpO2 / sleep score) from the per-night summary. Fetch failure stays
// isolated from the stage writes; database failures are hard sync failures.
//
// Withings API plans (2026-10-12): the sleep score, the overnight HRV and the
// respiration rate are Advanced Biomarkers, and one of them in `data_fields`
// has the whole summary refused with 277. Ask only for what the plan allows,
// remember what it refused, and re-probe weekly.
let summaries: WithingsSleepSummary[] = [];
try {
const planRow = await prisma.withingsConnection.findUnique({
where: { userId },
select: { apiPlanState: true },
});
const sleepPlan = parseWithingsApiPlanState(
planRow?.apiPlanState,
).sleepSummary;
const reprobed = isPlanReprobeDue(sleepPlan, now);
const skip = planSkipFor(sleepPlan, now);
const plan = await requestWithinPlan({
keys: SLEEP_SUMMARY_DATA_FIELDS.filter((f) => !skip.includes(f)),
knownGated: WITHINGS_ADVANCED_SLEEP_FIELDS,
fetch: (fields) =>
fetchWithingsSleepSummary(
tokenInfo.accessToken,
ymdUtc(start),
ymdUtc(now),
fields,
),
});
summaries = plan.results;
if (
shouldPersistPlanLeg({
gated: plan.gated,
reprobed,
acceptedAny: plan.acceptedAny,
})
) {
await persistWithingsPlanLeg(userId, "sleepSummary", {
unavailable: [...skip, ...plan.unavailable],
checkedAt: planCheckedAtFor(sleepPlan, reprobed, now),
});
}
} catch (err) {
getEvent()?.addWarning(
`withings sleep: summary fetch failed for ${userId}: ${err}`,
);
}
// / avg SpO2 / sleep score) from the per-night summary read above. Database
// failures are hard sync failures.
await prisma
.$transaction(
async (tx) => {
@@ -656,6 +703,103 @@ export async function syncUserSleep(
() => {},
);
if (summaryFailure !== null) {
await recordWithingsSyncFailure(userId, summaryFailure, WITHINGS_LEG_SLEEP);
return imported;
}
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_SLEEP });
return imported;
}
const DAY_SECONDS = 24 * 60 * 60;
/** Nights that ended this recently are read again on every run (re-scores). */
const RECENT_NIGHT_SECONDS = 2 * DAY_SECONDS;
/**
* Stage reads per run beyond the recent nights. A new connection, or a
* window the sleep-timeline backfill just cleared, fills in over a few runs
* instead of firing thirty requests at once; a full sync reads every night.
*/
export const SLEEP_STAGE_NIGHTS_PER_RUN = 8;
/** One `action=get` read: a night's own span, at most 24 hours. */
interface StageWindow {
startdate: number;
enddate: number;
/** The read covers the whole night, so its session may be swept. */
wholeNight: boolean;
}
/**
* The nights whose stage timeline this run reads, from the summary's night
* list: every night that ended, or that Withings changed (`modified`), in the
* last 48 hours, since Withings re-scores a night after the fact; then nights
* that hold no Withings stage row yet,
* up to `SLEEP_STAGE_NIGHTS_PER_RUN`. The missing nights rotate by the hour,
* so a night that never carries stages (a tracker without them) cannot keep
* the ones behind it waiting.
*/
async function stageWindowsToFetch(
userId: string,
summaries: readonly WithingsSleepSummary[],
now: Date,
opts: { all: boolean },
): Promise<StageWindow[]> {
const nights = new Map<
string,
{ startdate: number; enddate: number; changed: number }
>();
for (const s of summaries) {
if (!Number.isFinite(s.startdate) || !Number.isFinite(s.enddate)) continue;
if (s.enddate <= s.startdate) continue;
const modified = Number.isFinite(s.modified) ? s.modified! : 0;
const key = `${s.startdate}:${s.enddate}`;
nights.set(key, {
startdate: s.startdate,
enddate: s.enddate,
changed: Math.max(s.enddate, modified, nights.get(key)?.changed ?? 0),
});
}
const all = [...nights.values()].sort((x, y) => y.enddate - x.enddate);
const toWindow = (n: {
startdate: number;
enddate: number;
}): StageWindow => ({
startdate: n.startdate,
enddate: Math.min(n.enddate, n.startdate + DAY_SECONDS),
wholeNight: n.enddate - n.startdate <= DAY_SECONDS,
});
if (opts.all || all.length === 0) return all.map(toWindow);
const nowUnix = Math.floor(now.getTime() / 1000);
// Recent: ended, or was changed by Withings, within the last 48 hours.
const recent = all.filter((n) => n.changed >= nowUnix - RECENT_NIGHT_SECONDS);
const older = all.filter((n) => n.changed < nowUnix - RECENT_NIGHT_SECONDS);
if (older.length === 0) return recent.map(toWindow);
// Segments are stamped at their END, so a night holds a stage row when one
// ends inside (startdate, enddate]. One read over the span of the older
// nights, on the `(user_id, type, measured_at)` index.
const first = Math.min(...older.map((n) => n.startdate));
const last = Math.max(...older.map((n) => n.enddate));
const stored = await prisma.measurement.findMany({
where: {
userId,
source: "WITHINGS",
type: SLEEP_TYPE,
deletedAt: null,
measuredAt: { gt: new Date(first * 1000), lte: new Date(last * 1000) },
},
select: { measuredAt: true },
});
const endsAt = stored.map((row) => row.measuredAt.getTime() / 1000);
const missing = older
.filter((n) => !endsAt.some((t) => t > n.startdate && t <= n.enddate))
.sort((x, y) => x.enddate - y.enddate);
const budget = Math.max(0, SLEEP_STAGE_NIGHTS_PER_RUN - recent.length);
if (missing.length <= budget) return [...recent, ...missing].map(toWindow);
const offset = Math.floor(nowUnix / 3600) % missing.length;
const rotated = [...missing.slice(offset), ...missing.slice(0, offset)];
return [...recent, ...rotated.slice(0, budget)].map(toWindow);
}