From ba1271275fe025526cf4ac0caa202830ed2c24c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 12:46:14 +0200 Subject: [PATCH] feat(fitbit): stop at the Web API turndown and point to Google Health Google turns the legacy Fitbit Web API off on 30 October 2026 (support ended on 30 September). From that day the hourly poll, the backfill, the sleep repair and the token refresh make no call, the connect, sync and test routes refuse with `provider_retired`, and the connection reads as not connected to the health read and the watchdog, so the shutdown pages nobody and asks nobody to reconnect. The connection row and everything it imported stay. The status envelope publishes `retirement` on the Fitbit entry (the day, whether it has come, the successor), and the Fitbit card shows when the connection stops, how to move to Google Health (same devices, one more consent, tokens cannot be carried over) and that the data stays; sync and connect grey out after the day. Both integrations can run side by side meanwhile: every type Fitbit writes is laddered with Fitbit just above Google Health, so a day both deliver counts once. The test setups pin the turndown off, so the Fitbit suites do not turn red on the day; the turndown's own tests flip it. --- docs/api/openapi.yaml | 47 +++++++- docs/integrations/fitbit.md | 26 ++-- docs/integrations/google-health.md | 9 +- messages/de.json | 12 +- messages/en.json | 12 +- messages/es.json | 12 +- messages/fr.json | 12 +- messages/it.json | 12 +- messages/ko.json | 12 +- messages/nl.json | 12 +- messages/pl.json | 12 +- .../fitbit/connect/__tests__/route.test.ts | 13 ++ src/app/api/fitbit/connect/route.ts | 10 ++ .../api/fitbit/sync/__tests__/route.test.ts | 68 +++++++++++ src/app/api/fitbit/sync/route.ts | 9 ++ .../fitbit/test/__tests__/route.test.ts | 17 +++ src/app/api/integrations/fitbit/test/route.ts | 9 ++ src/app/api/integrations/status/route.ts | 7 ++ .../__tests__/oauth-return-outcome.test.ts | 9 ++ .../fitbit-retirement-notice.test.tsx | 47 ++++++++ .../integrations/connections-panel.tsx | 37 ++++-- .../integrations/fitbit-retirement-notice.tsx | 61 ++++++++++ .../integrations/oauth-integration-card.tsx | 17 ++- .../settings/integrations/shared.tsx | 3 + src/lib/fitbit/__tests__/retirement.test.ts | 37 ++++++ .../fitbit/__tests__/sync-user-fitbit.test.ts | 32 +++++ src/lib/fitbit/retirement.ts | 51 ++++++++ src/lib/fitbit/sync-core.ts | 6 + src/lib/fitbit/sync.ts | 8 ++ .../integrations/connection-health-load.ts | 6 +- .../jobs/__tests__/fitbit-backfill.test.ts | 26 ++++ .../__tests__/fitbit-sleep-repair.test.ts | 27 +++++ .../__tests__/fitbit-sync-retired.test.ts | 47 ++++++++ src/lib/jobs/fitbit-backfill.ts | 14 ++- src/lib/jobs/fitbit-sleep-repair.ts | 13 +- src/lib/jobs/reminder/fitbit-sync.ts | 15 +++ src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/integrations.ts | 31 ++++- tests/integration/environment-setup.ts | 23 +++- tests/integration/fitbit-retirement.test.ts | 113 ++++++++++++++++++ vitest.setup.ts | 24 ++++ 41 files changed, 920 insertions(+), 39 deletions(-) create mode 100644 src/app/api/fitbit/sync/__tests__/route.test.ts create mode 100644 src/components/settings/integrations/__tests__/fitbit-retirement-notice.test.tsx create mode 100644 src/components/settings/integrations/fitbit-retirement-notice.tsx create mode 100644 src/lib/fitbit/__tests__/retirement.test.ts create mode 100644 src/lib/fitbit/retirement.ts create mode 100644 src/lib/jobs/__tests__/fitbit-sync-retired.test.ts create mode 100644 tests/integration/fitbit-retirement.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d38b30a9f..3b3ff54f0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -20339,6 +20339,13 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "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: + $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 64 KiB. content: @@ -20953,6 +20960,13 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 + "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: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a49 "429": *a50 "502": *a51 @@ -36298,8 +36312,8 @@ components: health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only, - private_origin_not_approved, private_origin_not_grantable, rate_limited_self, sync_in_progress, - too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured" + private_origin_not_approved, private_origin_not_grantable, provider_retired, rate_limited_self, + sync_in_progress, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured" type: string capability: description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, @@ -79322,13 +79336,36 @@ components: allowPrivateHost: description: Nightscout only. type: boolean + retirement: + description: Fitbit only. The end of the legacy Fitbit Web API. + type: object + properties: + endsOn: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + description: The day Google turns the legacy Fitbit Web API off. + ended: + type: boolean + description: "The day has come: the connection no longer syncs, its imported data stays, and `health` is null." + successor: + type: string + enum: + - google-health + description: The integration that reads the same Fitbit and Pixel devices. Moving needs a fresh consent; the Fitbit + grant cannot be carried over. + required: + - endsOn + - ended + - successor + additionalProperties: false health: anyOf: - $ref: "#/components/schemas/ConnectionHealth" - type: "null" - description: 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. + description: 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. required: - integration - state diff --git a/docs/integrations/fitbit.md b/docs/integrations/fitbit.md index 7e4310c39..4c5684c21 100644 --- a/docs/integrations/fitbit.md +++ b/docs/integrations/fitbit.md @@ -16,14 +16,24 @@ Settings. The internal integration key is `fitbit`. > consent screen with `unauthorized_client — Invalid client_id`, because Fitbit > and Google Cloud are separate client registries. -> **Heads-up — new registrations are closed.** Fitbit has **discontinued -> self-serve app registration**: the form at `dev.fitbit.com/apps/new` is -> disabled for new applications, and the classic Fitbit Web API sunsets in -> **September 2026**. If you already hold a registered Fitbit app, it keeps -> working until the sunset and the setup below still applies. If you are -> connecting Fitbit data for the first time, use the -> [Google Health integration](google-health.md) instead — Fitbit devices sync -> into Google's health stack, which HealthLog reads directly. +> **The classic Fitbit Web API is turned off on 30 October 2026.** Google +> ended support for it on 30 September 2026 and turns it off on +> **30 October 2026**, after which it "will no longer function or sync data" +> ([Google's migration guide](https://developers.google.com/health/migration)). +> New app registrations at `dev.fitbit.com/apps/new` are already closed. +> +> From that day HealthLog stops calling the Web API for every Fitbit +> connection: no hourly sync, no backfill, no token refresh, and no new +> connection. Nothing is deleted. The connection and every reading it imported +> stay, and the Fitbit card explains the move instead of asking for a +> reconnect. To keep Fitbit and Pixel data coming, connect the +> [Google Health integration](google-health.md). It reads the same devices, +> but Google asks for consent once more: Fitbit tokens cannot be carried over. +> +> Both integrations can run side by side while you move. Where both deliver the +> same day, the source-priority ladder keeps one of them per day (Fitbit ranks +> just above Google Health by default), so steps, sleep and the other daily +> values are not counted twice. After the turndown only Google Health delivers. ## 1. Use an existing Fitbit developer app diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md index 554f1ed1f..069d09e83 100644 --- a/docs/integrations/google-health.md +++ b/docs/integrations/google-health.md @@ -2,9 +2,12 @@ HealthLog reads Fitbit, Pixel Watch and Fitbit Air data through the **Google Health API** (`health.googleapis.com/v4`), the successor to the classic Fitbit -Web API. Google retires the classic Fitbit Web API in **September 2026**, and -Fitbit access/refresh tokens do not carry over — every account re-consents -through Google OAuth. This integration is that path. +Web API. Google ended support for the classic Fitbit Web API on 30 September +2026 and turns it off on **30 October 2026** +([migration guide](https://developers.google.com/health/migration)); from that +day HealthLog's `fitbit` connection stops syncing and keeps what it imported. +Fitbit access/refresh tokens do not carry over, so every account consents once +more through Google OAuth. This integration is that path. The OAuth app is a **Google Cloud** OAuth client, not a Fitbit developer-console app. Credentials are brought **per user**: each user registers their own Google diff --git a/messages/de.json b/messages/de.json index 90d17c3c7..9fa1c2120 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6619,7 +6619,8 @@ "nocode": "Fitbit hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Fitbit-Dialog.", "nocreds": "Fitbit ist nicht konfiguriert. Hinterlege oben deine Fitbit-OAuth-Zugangsdaten und versuche es erneut.", "token": "Der Token-Austausch mit Fitbit ist fehlgeschlagen. Bitte starte die Verbindung erneut.", - "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.", + "retired": "Google hat die Fitbit-Web-API abgeschaltet, deshalb lässt sich keine neue Fitbit-Verbindung mehr herstellen. Verbinde stattdessen Google Health, das dieselben Geräte liest." }, "integrations": { "withings": { @@ -7439,6 +7440,15 @@ "homePlaceUnknown": "Diesen Ort kennt die Dawarich-Instanz nicht mehr.", "notConnected": "Dawarich ist nicht verbunden.", "generic": "Das hat nicht geklappt. Versuche es erneut." + }, + "fitbitRetirement": { + "ending": "Google schaltet die Fitbit-Web-API am {date} ab. Ab dann synchronisiert diese Verbindung nicht mehr.", + "ended": "Google hat die Fitbit-Web-API am {date} abgeschaltet. Diese Verbindung synchronisiert nicht mehr.", + "move": "Google Health liest dieselben Fitbit- und Pixel-Geräte. Verbinde es unten, damit deine Daten weiter ankommen. Google fragt dich noch einmal nach deiner Zustimmung, weil sich die Fitbit-Verbindung nicht übertragen lässt. Alles bereits Importierte bleibt erhalten.", + "toCard": "Zu Google Health", + "guide": "So verbindest du Google Health", + "badgeEnding": "Wird abgeschaltet", + "badgeEnded": "Abgeschaltet" } }, "admin": { diff --git a/messages/en.json b/messages/en.json index eba85e21c..b682014c7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6619,7 +6619,8 @@ "nocode": "Fitbit did not return an authorization code. Try again and allow access in the Fitbit dialog.", "nocreds": "Fitbit is not configured. Add your Fitbit OAuth credentials above and try again.", "token": "The token exchange with Fitbit failed. Please start the connection again.", - "generic": "Unknown error while connecting. Please start the connection again." + "generic": "Unknown error while connecting. Please start the connection again.", + "retired": "Google has turned the Fitbit Web API off, so no new Fitbit connection can be made. Connect Google Health instead, which reads the same devices." }, "integrations": { "withings": { @@ -7439,6 +7440,15 @@ "homePlaceUnknown": "The Dawarich instance no longer knows this place.", "notConnected": "Dawarich is not connected.", "generic": "That did not work. Try again." + }, + "fitbitRetirement": { + "ending": "Google turns the Fitbit Web API off on {date}. From then on this connection stops syncing.", + "ended": "Google turned the Fitbit Web API off on {date}. This connection no longer syncs.", + "move": "Google Health reads the same Fitbit and Pixel devices. Connect it below to keep your data coming. Google asks for your consent once more, because the Fitbit connection cannot be carried over. Everything already imported stays.", + "toCard": "Go to Google Health", + "guide": "How to connect Google Health", + "badgeEnding": "Shutting down", + "badgeEnded": "Retired" } }, "admin": { diff --git a/messages/es.json b/messages/es.json index f89ebd220..340e12dfa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6619,7 +6619,8 @@ "nocode": "Fitbit no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Fitbit.", "nocreds": "Fitbit no está configurado. Añade tus credenciales OAuth de Fitbit arriba e inténtalo de nuevo.", "token": "El intercambio de token con Fitbit falló. Inicia la conexión de nuevo.", - "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo.", + "retired": "Google desactivó la API web de Fitbit, así que ya no se puede crear una nueva conexión de Fitbit. Conecta Google Health, que lee los mismos dispositivos." }, "integrations": { "withings": { @@ -7439,6 +7440,15 @@ "homePlaceUnknown": "La instancia de Dawarich ya no conoce este lugar.", "notConnected": "Dawarich no está conectado.", "generic": "No ha funcionado. Inténtalo de nuevo." + }, + "fitbitRetirement": { + "ending": "Google desactivará la API web de Fitbit el {date}. A partir de entonces, esta conexión dejará de sincronizar.", + "ended": "Google desactivó la API web de Fitbit el {date}. Esta conexión ya no sincroniza.", + "move": "Google Health lee los mismos dispositivos Fitbit y Pixel. Conéctalo abajo para que tus datos sigan llegando. Google te pedirá tu consentimiento otra vez, porque la conexión de Fitbit no se puede transferir. Todo lo ya importado se conserva.", + "toCard": "Ir a Google Health", + "guide": "Cómo conectar Google Health", + "badgeEnding": "Se desactiva pronto", + "badgeEnded": "Desactivado" } }, "admin": { diff --git a/messages/fr.json b/messages/fr.json index eada370a5..daec5830d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6619,7 +6619,8 @@ "nocode": "Fitbit n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Fitbit.", "nocreds": "Fitbit n'est pas configuré. Ajoutez vos identifiants OAuth Fitbit ci-dessus et réessayez.", "token": "L'échange de jeton avec Fitbit a échoué. Veuillez relancer la connexion.", - "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." + "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.", + "retired": "Google a désactivé l'API Web Fitbit : il n'est plus possible de créer une nouvelle connexion Fitbit. Connectez plutôt Google Health, qui lit les mêmes appareils." }, "integrations": { "withings": { @@ -7439,6 +7440,15 @@ "homePlaceUnknown": "L’instance Dawarich ne connaît plus ce lieu.", "notConnected": "Dawarich n’est pas connecté.", "generic": "Ça n’a pas marché. Réessaie." + }, + "fitbitRetirement": { + "ending": "Google désactive l'API Web Fitbit le {date}. Ensuite, cette connexion ne se synchronisera plus.", + "ended": "Google a désactivé l'API Web Fitbit le {date}. Cette connexion ne se synchronise plus.", + "move": "Google Health lit les mêmes appareils Fitbit et Pixel. Connectez-le ci-dessous pour continuer à recevoir vos données. Google vous demandera de nouveau votre consentement, car la connexion Fitbit ne peut pas être transférée. Tout ce qui a déjà été importé est conservé.", + "toCard": "Aller à Google Health", + "guide": "Comment connecter Google Health", + "badgeEnding": "Bientôt désactivé", + "badgeEnded": "Désactivé" } }, "admin": { diff --git a/messages/it.json b/messages/it.json index 42929075e..866de0e05 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6619,7 +6619,8 @@ "nocode": "Fitbit non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Fitbit.", "nocreds": "Fitbit non è configurato. Aggiungi le tue credenziali OAuth di Fitbit qui sopra e riprova.", "token": "Lo scambio del token con Fitbit è fallito. Riavvia la connessione.", - "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.", + "retired": "Google ha disattivato l'API web di Fitbit, quindi non è più possibile creare una nuova connessione Fitbit. Collega invece Google Health, che legge gli stessi dispositivi." }, "integrations": { "withings": { @@ -7439,6 +7440,15 @@ "homePlaceUnknown": "L’istanza Dawarich non conosce più questo luogo.", "notConnected": "Dawarich non è collegato.", "generic": "Non ha funzionato. Riprova." + }, + "fitbitRetirement": { + "ending": "Google disattiva l'API web di Fitbit il {date}. Da quel momento questa connessione smette di sincronizzarsi.", + "ended": "Google ha disattivato l'API web di Fitbit il {date}. Questa connessione non si sincronizza più.", + "move": "Google Health legge gli stessi dispositivi Fitbit e Pixel. Collegalo qui sotto per continuare a ricevere i tuoi dati. Google ti chiederà di nuovo il consenso, perché la connessione Fitbit non può essere trasferita. Tutto ciò che è già stato importato resta.", + "toCard": "Vai a Google Health", + "guide": "Come collegare Google Health", + "badgeEnding": "In dismissione", + "badgeEnded": "Dismesso" } }, "admin": { diff --git a/messages/ko.json b/messages/ko.json index ffd1b4c6f..7722828c9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6619,7 +6619,8 @@ "nocode": "Fitbit이 인증 코드를 보내지 않았어요. 다시 시도하고 Fitbit 창에서 접근을 허용해 주세요.", "nocreds": "Fitbit이 설정되지 않았어요. 위에 Fitbit OAuth 자격 증명을 추가하고 다시 시도해 주세요.", "token": "Fitbit과의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", - "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요." + "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.", + "retired": "Google이 Fitbit Web API를 종료해서 새 Fitbit 연결을 만들 수 없어요. 같은 기기의 데이터를 읽는 Google Health를 대신 연결해 주세요." }, "integrations": { "withings": { @@ -7439,6 +7440,15 @@ "homePlaceUnknown": "Dawarich 인스턴스에 이 장소가 더 이상 없습니다.", "notConnected": "Dawarich가 연결되어 있지 않습니다.", "generic": "실패했습니다. 다시 시도하세요." + }, + "fitbitRetirement": { + "ending": "Google이 {date}에 Fitbit Web API를 종료해요. 그 이후로 이 연결은 더 이상 동기화되지 않아요.", + "ended": "Google이 {date}에 Fitbit Web API를 종료했어요. 이 연결은 더 이상 동기화되지 않아요.", + "move": "Google Health는 같은 Fitbit 및 Pixel 기기의 데이터를 읽어요. 데이터를 계속 받으려면 아래에서 연결해 주세요. Fitbit 연결은 옮길 수 없어서 Google이 동의를 한 번 더 요청해요. 이미 가져온 데이터는 모두 그대로 남아요.", + "toCard": "Google Health로 이동", + "guide": "Google Health 연결 방법", + "badgeEnding": "종료 예정", + "badgeEnded": "종료됨" } }, "admin": { diff --git a/messages/nl.json b/messages/nl.json index d42032ded..0bb6c3f82 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6619,7 +6619,8 @@ "nocode": "Fitbit heeft geen autorisatiecode teruggestuurd. Probeer het opnieuw en sta toegang toe in het Fitbit-venster.", "nocreds": "Fitbit is niet geconfigureerd. Voeg hierboven je Fitbit-OAuth-inloggegevens toe en probeer het opnieuw.", "token": "De tokenuitwisseling met Fitbit is mislukt. Start de koppeling opnieuw.", - "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw." + "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.", + "retired": "Google heeft de Fitbit Web API uitgeschakeld, dus er kan geen nieuwe Fitbit-koppeling meer worden gemaakt. Koppel in plaats daarvan Google Health, dat dezelfde apparaten leest." }, "integrations": { "withings": { @@ -7439,6 +7440,15 @@ "homePlaceUnknown": "De Dawarich-instantie kent deze plaats niet meer.", "notConnected": "Dawarich is niet gekoppeld.", "generic": "Dat is niet gelukt. Probeer het opnieuw." + }, + "fitbitRetirement": { + "ending": "Google schakelt de Fitbit Web API uit op {date}. Vanaf dan synchroniseert deze koppeling niet meer.", + "ended": "Google heeft de Fitbit Web API uitgeschakeld op {date}. Deze koppeling synchroniseert niet meer.", + "move": "Google Health leest dezelfde Fitbit- en Pixel-apparaten. Koppel het hieronder om je gegevens te blijven ontvangen. Google vraagt nog een keer om je toestemming, omdat de Fitbit-koppeling niet kan worden overgezet. Alles wat al is geïmporteerd blijft bewaard.", + "toCard": "Naar Google Health", + "guide": "Zo koppel je Google Health", + "badgeEnding": "Wordt uitgeschakeld", + "badgeEnded": "Uitgeschakeld" } }, "admin": { diff --git a/messages/pl.json b/messages/pl.json index 6985f25f3..124c3a6b3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6619,7 +6619,8 @@ "nocode": "Fitbit nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Fitbit.", "nocreds": "Fitbit nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Fitbit i spróbuj ponownie.", "token": "Wymiana tokena z Fitbit nie powiodła się. Rozpocznij połączenie ponownie.", - "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", + "retired": "Google wyłączył Fitbit Web API, więc nie można już utworzyć nowego połączenia Fitbit. Połącz zamiast tego Google Health, które odczytuje dane z tych samych urządzeń." }, "integrations": { "withings": { @@ -7439,6 +7440,15 @@ "homePlaceUnknown": "Instancja Dawarich nie zna już tego miejsca.", "notConnected": "Dawarich nie jest połączony.", "generic": "Nie udało się. Spróbuj ponownie." + }, + "fitbitRetirement": { + "ending": "Google wyłączy Fitbit Web API dnia {date}. Od tego dnia to połączenie przestanie się synchronizować.", + "ended": "Google wyłączył Fitbit Web API dnia {date}. To połączenie już się nie synchronizuje.", + "move": "Google Health odczytuje dane z tych samych urządzeń Fitbit i Pixel. Połącz je poniżej, aby dane nadal napływały. Google poprosi o zgodę jeszcze raz, ponieważ połączenia Fitbit nie da się przenieść. Wszystko, co już zaimportowano, zostaje.", + "toCard": "Przejdź do Google Health", + "guide": "Jak połączyć Google Health", + "badgeEnding": "Wkrótce wyłączone", + "badgeEnded": "Wyłączone" } }, "admin": { diff --git a/src/app/api/fitbit/connect/__tests__/route.test.ts b/src/app/api/fitbit/connect/__tests__/route.test.ts index aa71ee8a4..003b50857 100644 --- a/src/app/api/fitbit/connect/__tests__/route.test.ts +++ b/src/app/api/fitbit/connect/__tests__/route.test.ts @@ -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); + } + }); }); diff --git a/src/app/api/fitbit/connect/route.ts b/src/app/api/fitbit/connect/route.ts index e9380b63f..11823963b 100644 --- a/src/app/api/fitbit/connect/route.ts +++ b/src/app/api/fitbit/connect/route.ts @@ -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" } }); diff --git a/src/app/api/fitbit/sync/__tests__/route.test.ts b/src/app/api/fitbit/sync/__tests__/route.test.ts new file mode 100644 index 000000000..b96e44345 --- /dev/null +++ b/src/app/api/fitbit/sync/__tests__/route.test.ts @@ -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("@/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; + +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); + } + }); +}); diff --git a/src/app/api/fitbit/sync/route.ts b/src/app/api/fitbit/sync/route.ts index be981267e..0c70598d6 100644 --- a/src/app/api/fitbit/sync/route.ts +++ b/src/app/api/fitbit/sync/route.ts @@ -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; diff --git a/src/app/api/integrations/fitbit/test/__tests__/route.test.ts b/src/app/api/integrations/fitbit/test/__tests__/route.test.ts index 31481035d..58a55d5b1 100644 --- a/src/app/api/integrations/fitbit/test/__tests__/route.test.ts +++ b/src/app/api/integrations/fitbit/test/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/integrations/fitbit/test/route.ts b/src/app/api/integrations/fitbit/test/route.ts index 509e617bc..c9be8f9b1 100644 --- a/src/app/api/integrations/fitbit/test/route.ts +++ b/src/app/api/integrations/fitbit/test/route.ts @@ -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, { diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 73396103d..04917d270 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -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 diff --git a/src/components/settings/__tests__/oauth-return-outcome.test.ts b/src/components/settings/__tests__/oauth-return-outcome.test.ts index e768a03dd..caa988bdb 100644 --- a/src/components/settings/__tests__/oauth-return-outcome.test.ts +++ b/src/components/settings/__tests__/oauth-return-outcome.test.ts @@ -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", diff --git a/src/components/settings/integrations/__tests__/fitbit-retirement-notice.test.tsx b/src/components/settings/integrations/__tests__/fitbit-retirement-notice.test.tsx new file mode 100644 index 000000000..21fd77d23 --- /dev/null +++ b/src/components/settings/integrations/__tests__/fitbit-retirement-notice.test.tsx @@ -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( + + + , + ); +} + +describe("", () => { + 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(""); + }); +}); diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx index bb4fa54cd..6382cdd54 100644 --- a/src/components/settings/integrations/connections-panel.tsx +++ b/src/components/settings/integrations/connections-panel.tsx @@ -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> > = { 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({ />
+ {t("settings.fitbitTag")} - - {t("settings.fitbitExperimentalBadge")} - + {fitbitStatus?.retirement ? ( + + {fitbitRetired + ? t("settings.fitbitRetirement.badgeEnded") + : t("settings.fitbitRetirement.badgeEnding")} + + ) : ( + + {t("settings.fitbitExperimentalBadge")} + + )} } /> diff --git a/src/components/settings/integrations/fitbit-retirement-notice.tsx b/src/components/settings/integrations/fitbit-retirement-notice.tsx new file mode 100644 index 000000000..de56f1ca1 --- /dev/null +++ b/src/components/settings/integrations/fitbit-retirement-notice.tsx @@ -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 ( +
+

+ {retirement.ended + ? t("settings.fitbitRetirement.ended", { date }) + : t("settings.fitbitRetirement.ending", { date })} +

+

{t("settings.fitbitRetirement.move")}

+ +
+ ); +} diff --git a/src/components/settings/integrations/oauth-integration-card.tsx b/src/components/settings/integrations/oauth-integration-card.tsx index 9af68d69e..d3f6eb1af 100644 --- a/src/components/settings/integrations/oauth-integration-card.tsx +++ b/src/components/settings/integrations/oauth-integration-card.tsx @@ -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 ; + return ( + + ); } /** @@ -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" diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx index c5c558ef3..f9afa9193 100644 --- a/src/components/settings/integrations/shared.tsx +++ b/src/components/settings/integrations/shared.tsx @@ -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 { diff --git a/src/lib/fitbit/__tests__/retirement.test.ts b/src/lib/fitbit/__tests__/retirement.test.ts new file mode 100644 index 000000000..7a95cb230 --- /dev/null +++ b/src/lib/fitbit/__tests__/retirement.test.ts @@ -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( + "@/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); + }); +}); diff --git a/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts b/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts index 6fd5448f2..2be8cada5 100644 --- a/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts +++ b/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts @@ -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); + } + }); +}); diff --git a/src/lib/fitbit/retirement.ts b/src/lib/fitbit/retirement.ts new file mode 100644 index 000000000..3f42a7f52 --- /dev/null +++ b/src/lib/fitbit/retirement.ts @@ -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", + }; +} diff --git a/src/lib/fitbit/sync-core.ts b/src/lib/fitbit/sync-core.ts index e52c656c9..d78d1e28d 100644 --- a/src/lib/fitbit/sync-core.ts +++ b/src/lib/fitbit/sync-core.ts @@ -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 { + if (isFitbitRetired()) return null; const connection = await prisma.fitbitConnection.findUnique({ where: { userId }, }); diff --git a/src/lib/fitbit/sync.ts b/src/lib/fitbit/sync.ts index 84f1f84e5..3e45ac27d 100644 --- a/src/lib/fitbit/sync.ts +++ b/src/lib/fitbit/sync.ts @@ -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`, diff --git a/src/lib/integrations/connection-health-load.ts b/src/lib/integrations/connection-health-load.ts index 4ada42924..e0134cb09 100644 --- a/src/lib/integrations/connection-health-load.ts +++ b/src/lib/integrations/connection-health-load.ts @@ -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 = { 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, diff --git a/src/lib/jobs/__tests__/fitbit-backfill.test.ts b/src/lib/jobs/__tests__/fitbit-backfill.test.ts index b34e9972a..fb1720bfa 100644 --- a/src/lib/jobs/__tests__/fitbit-backfill.test.ts +++ b/src/lib/jobs/__tests__/fitbit-backfill.test.ts @@ -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); + } + }); +}); diff --git a/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts b/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts index f35929a20..f768c9799 100644 --- a/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts +++ b/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts @@ -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); + } + }); +}); diff --git a/src/lib/jobs/__tests__/fitbit-sync-retired.test.ts b/src/lib/jobs/__tests__/fitbit-sync-retired.test.ts new file mode 100644 index 000000000..445930b16 --- /dev/null +++ b/src/lib/jobs/__tests__/fitbit-sync-retired.test.ts @@ -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); + } + }); +}); diff --git a/src/lib/jobs/fitbit-backfill.ts b/src/lib/jobs/fitbit-backfill.ts index 7fbb0e8f4..ad457bfae 100644 --- a/src/lib/jobs/fitbit-backfill.ts +++ b/src/lib/jobs/fitbit-backfill.ts @@ -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 }; } diff --git a/src/lib/jobs/fitbit-sleep-repair.ts b/src/lib/jobs/fitbit-sleep-repair.ts index 13cb7c3f4..0b681ff1b 100644 --- a/src/lib/jobs/fitbit-sleep-repair.ts +++ b/src/lib/jobs/fitbit-sleep-repair.ts @@ -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 }; } diff --git a/src/lib/jobs/reminder/fitbit-sync.ts b/src/lib/jobs/reminder/fitbit-sync.ts index e55886ff5..9a560eb38 100644 --- a/src/lib/jobs/reminder/fitbit-sync.ts +++ b/src/lib/jobs/reminder/fitbit-sync.ts @@ -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[], ): Promise { 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 }> = []; diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 7849dc564..e509e18b3 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -484,6 +484,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "oidc_only", "private_origin_not_approved", "private_origin_not_grantable", + "provider_retired", "rate_limited_self", "sync_in_progress", "too_many_rows", diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index d0e0500b2..212eb487b 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -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 = { "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 = { ...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 } }, + }, }, }, }, diff --git a/tests/integration/environment-setup.ts b/tests/integration/environment-setup.ts index a3c7c4da6..2613d151e 100644 --- a/tests/integration/environment-setup.ts +++ b/tests/integration/environment-setup.ts @@ -1,4 +1,4 @@ -import { afterEach, inject } from "vitest"; +import { afterEach, inject, vi } from "vitest"; declare module "vitest" { export interface ProvidedContext { @@ -32,3 +32,24 @@ afterEach(async () => { await import("@/lib/logging/background-tasks"); await settleBackgroundTasks(); }); + +/** + * The same pin the unit setup applies (see `vitest.setup.ts`): the Fitbit + * suites run before the Web API turndown, whatever the real date, and a test + * about the turndown flips `isFitbitRetired` itself. The factory imports the + * module lazily, so nothing of the application loads before DATABASE_URL is + * bridged above. + */ +vi.mock("@/lib/fitbit/retirement", async (importOriginal) => { + const actual = + await importOriginal(); + const isFitbitRetired = vi.fn((_now?: Date) => false); + return { + ...actual, + isFitbitRetired, + fitbitRetirement: vi.fn((now?: Date) => ({ + ...actual.fitbitRetirement(now), + ended: isFitbitRetired(now), + })), + }; +}); diff --git a/tests/integration/fitbit-retirement.test.ts b/tests/integration/fitbit-retirement.test.ts new file mode 100644 index 000000000..b75f0d2b3 --- /dev/null +++ b/tests/integration/fitbit-retirement.test.ts @@ -0,0 +1,113 @@ +/** + * After Google turned the Fitbit Web API off, a Fitbit connection that has + * gone quiet is the provider's shutdown, not a broken pipe. Against real + * Postgres: the health read publishes no state for it (so no card or banner + * asks for a reconnect), the watchdog pages nobody, and the connection row + * and every imported reading stay. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/notifications/dispatcher", () => ({ + dispatchNotification: vi.fn().mockResolvedValue({ + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, + }), +})); + +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { runIntegrationWatchdog } from "@/lib/integrations/watchdog"; +import { loadConnectionHealth } from "@/lib/integrations/connection-health-load"; +import { recordSyncSuccess } from "@/lib/integrations/status"; +import { isFitbitRetired } from "@/lib/fitbit/retirement"; + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; +const NOW = new Date("2026-11-03T12:00:00.000Z"); +const USER = "user-fitbit-retired"; +const ADMIN = "user-fitbit-retired-admin"; +const prisma = () => getPrismaClient(); + +beforeEach(async () => { + await truncateAllTables(prisma()); + await prisma().user.create({ + data: { + id: ADMIN, + username: "fr-admin", + email: "admin@example.test", + role: "ADMIN", + }, + }); + await prisma().user.create({ + data: { id: USER, username: "fr-subject", email: "subject@example.test" }, + }); + await prisma().fitbitConnection.create({ + data: { + userId: USER, + fitbitUserId: "fb-1", + accessToken: "not-a-real-token", + refreshToken: "not-a-real-token", + tokenExpiresAt: new Date(NOW.getTime() - DAY), + lastSyncedAt: new Date(NOW.getTime() - 5 * DAY), + }, + }); + // A fortnight of daily steps that stopped with the turndown. + await prisma().measurement.createMany({ + data: Array.from({ length: 14 }, (_, i) => ({ + userId: USER, + type: "ACTIVITY_STEPS" as const, + value: 8000, + unit: "count", + source: "FITBIT" as const, + measuredAt: new Date(NOW.getTime() - (i + 5) * DAY), + externalId: `stats:day-${i}`, + })), + }); + vi.mocked(dispatchNotification).mockClear(); + // The watchdog has run on this instance before, so it pages on a fresh + // incident instead of recording what stands. + await prisma().integrationWatch.create({ + data: { userId: ADMIN, integration: "withings" }, + }); +}); + +describe("Fitbit after the Web API turndown", () => { + it("still reads as a connection with a health state before the day", async () => { + await recordSyncSuccess(USER, "fitbit"); + const health = await loadConnectionHealth(USER, ["fitbit"]); + expect(health.fitbit).not.toBeNull(); + }); + + it("publishes no health, pages nobody, and keeps the row and the data", async () => { + await recordSyncSuccess(USER, "fitbit"); + vi.mocked(isFitbitRetired).mockReturnValue(true); + try { + const health = await loadConnectionHealth(USER, ["fitbit"]); + expect(health.fitbit).toBeNull(); + + await runIntegrationWatchdog({ mode: "hourly", now: NOW }); + await runIntegrationWatchdog({ mode: "daily", now: NOW }); + expect(dispatchNotification).not.toHaveBeenCalled(); + const watch = await prisma().integrationWatch.findUnique({ + where: { userId_integration: { userId: USER, integration: "fitbit" } }, + }); + expect(watch?.alertedAt ?? null).toBeNull(); + } finally { + vi.mocked(isFitbitRetired).mockReturnValue(false); + } + + expect( + await prisma().fitbitConnection.count({ where: { userId: USER } }), + ).toBe(1); + expect( + await prisma().measurement.count({ + where: { userId: USER, source: "FITBIT" }, + }), + ).toBe(14); + }); +}); diff --git a/vitest.setup.ts b/vitest.setup.ts index 8e1a180de..5412a6d6d 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -59,3 +59,27 @@ vi.mock("@/lib/auth/write-meter", async (importOriginal) => { await importOriginal(); return { ...actual, meterAccountWrite: vi.fn(async () => undefined) }; }); + +/** + * The end of the legacy Fitbit Web API is a calendar date + * (`src/lib/fitbit/retirement.ts`). Read off the real clock, every suite that + * exercises the Fitbit sync, its jobs or its routes would turn red on that day + * and stay red until the provider code is removed. The suites therefore run + * before the turndown by default; a test about the turndown itself flips + * `isFitbitRetired` (`vi.mocked(isFitbitRetired).mockReturnValue(true)`) or + * loads the real module with `vi.importActual`, as + * `src/lib/fitbit/__tests__/retirement.test.ts` does. + */ +vi.mock("@/lib/fitbit/retirement", async (importOriginal) => { + const actual = + await importOriginal(); + const isFitbitRetired = vi.fn((_now?: Date) => false); + return { + ...actual, + isFitbitRetired, + fitbitRetirement: vi.fn((now?: Date) => ({ + ...actual.fitbitRetirement(now), + ended: isFitbitRetired(now), + })), + }; +});