From e9ae024035a967d2eeed5d52401c8761eca9b541 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 4 Nov 2026 23:59:18 +0100
Subject: [PATCH] fix(onboarding): connect a Fitbit answer through Google
Health
Fitbit shut its classic Web API down on 2026-10-30 and Fitbit data now
arrives through Google Health, yet answering "Fitbit" still ended the setup
on the classic card, read the classic connection's status and named it.
Fitbit stays an answer, because people still own the devices. Its task now
opens the Google Health card, reads that connection, is named after it, and
says on every tile that Fitbit data comes through Google Health. The
question carries the same hint under the Fitbit choice, and the checklist
names the connection the same way.
The onboarding-source guard keeps Google Health out of the questions, now
with a reason a test holds: the Fitbit answer is the way there.
---
CHANGELOG.md | 6 ++
messages/de.json | 4 +-
messages/en.json | 4 +-
messages/es.json | 4 +-
messages/fr.json | 4 +-
messages/it.json | 4 +-
messages/ko.json | 4 +-
messages/nl.json | 4 +-
messages/pl.json | 4 +-
messages/pt-BR.json | 4 +-
messages/ru.json | 4 +-
.../__tests__/first-result-screen.test.tsx | 64 ++++++++++++++++++-
.../onboarding/first-result-screen.tsx | 59 ++++++++++++-----
.../onboarding/getting-started-checklist.tsx | 6 +-
src/components/onboarding/question-screen.tsx | 1 +
.../__tests__/first-result-config.test.ts | 62 +++++++++++++++++-
src/lib/onboarding/first-result-config.ts | 53 +++++++++++++--
17 files changed, 252 insertions(+), 39 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2cc569fa9..21a095de0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,12 @@
- **Garmin in the setup questions.** The onboarding question about where
your readings come from now offers Garmin, and can end on connecting it.
(#1333)
+- **Fitbit in the setup questions leads to Google Health.** Fitbit shut its
+ classic connection down on 30 October, but answering "Fitbit" still ended
+ the setup on that card. Fitbit stays an answer, since the devices are still
+ in use; the last step now opens the Google Health card, reads that
+ connection, and says that Fitbit data comes through Google Health now. The
+ question shows the same note under the Fitbit choice.
- **Cycle days in the integration details.** The Garmin and Oura cards list
the newest imported cycle day and how many there are under "Data
received", next to the other data types. (#1333)
diff --git a/messages/de.json b/messages/de.json
index 27a85f03f..88c823888 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5286,7 +5286,8 @@
},
"hints": {
"apple-health": "Wird von der iOS-App synchronisiert oder aus einem Export importiert.",
- "file": "Wird unter Einstellungen importiert, sobald du hier fertig bist."
+ "file": "Wird unter Einstellungen importiert, sobald du hier fertig bist.",
+ "fitbit": "Wird jetzt über Google Health verbunden."
}
},
"visit": {
@@ -5340,6 +5341,7 @@
"waiting": "Noch nicht verbunden.",
"openSettings": "Einstellungen → Integrationen öffnen",
"noDate": "unbekannt",
+ "throughGoogleHealth": "{source}-Daten kommen jetzt über {target}: Die klassische {source}-Verbindung wurde abgeschaltet. Verbinde {target} mit dem Google-Konto, an dem dein {source} angemeldet ist, dann kommen deine Werte von dort.",
"needsCredentials": {
"title": "Diese Instanz hat noch keine {target}-App hinterlegt.",
"detail": "Trage deine eigenen Zugangsdaten unter Einstellungen → Integrationen ein, dann kann die Verbindung starten."
diff --git a/messages/en.json b/messages/en.json
index db0a99616..211d1db22 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5286,7 +5286,8 @@
},
"hints": {
"apple-health": "Synced by the iOS app, or imported from an export.",
- "file": "Imported under Settings once you are done here."
+ "file": "Imported under Settings once you are done here.",
+ "fitbit": "Connected through Google Health now."
}
},
"visit": {
@@ -5340,6 +5341,7 @@
"waiting": "Not connected yet.",
"openSettings": "Open Settings → Integrations",
"noDate": "unknown",
+ "throughGoogleHealth": "{source} data now comes through {target}: the classic {source} connection has been shut down. Connect {target} with the Google account your {source} is signed in to, and your readings arrive from there.",
"needsCredentials": {
"title": "This instance has no {target} app on file yet.",
"detail": "Enter your own credentials under Settings → Integrations, then the connection can start."
diff --git a/messages/es.json b/messages/es.json
index fa997d617..f5fb1d686 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5286,7 +5286,8 @@
},
"hints": {
"apple-health": "Sincronizado por la app de iOS o importado desde una exportación.",
- "file": "Se importa en Ajustes cuando termines aquí."
+ "file": "Se importa en Ajustes cuando termines aquí.",
+ "fitbit": "Ahora se conecta a través de Google Health."
}
},
"visit": {
@@ -5340,6 +5341,7 @@
"waiting": "Aún no conectado.",
"openSettings": "Abrir Ajustes → Integraciones",
"noDate": "desconocido",
+ "throughGoogleHealth": "Los datos de {source} llegan ahora a través de {target}: la conexión clásica de {source} se ha cerrado. Conecta {target} con la cuenta de Google en la que tienes iniciada la sesión de {source} y tus lecturas llegarán desde ahí.",
"needsCredentials": {
"title": "Esta instancia aún no tiene una aplicación de {target} registrada.",
"detail": "Introduce tus propias credenciales en Ajustes → Integraciones y la conexión podrá empezar."
diff --git a/messages/fr.json b/messages/fr.json
index f6dfc8411..0925ac845 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5286,7 +5286,8 @@
},
"hints": {
"apple-health": "Synchronisé par l'app iOS, ou importé depuis un export.",
- "file": "À importer dans les réglages une fois cette étape terminée."
+ "file": "À importer dans les réglages une fois cette étape terminée.",
+ "fitbit": "Se connecte désormais via Google Health."
}
},
"visit": {
@@ -5340,6 +5341,7 @@
"waiting": "Pas encore connecté.",
"openSettings": "Ouvrir Paramètres → Intégrations",
"noDate": "inconnue",
+ "throughGoogleHealth": "Les données {source} passent désormais par {target} : l'ancienne connexion {source} a été arrêtée. Connectez {target} avec le compte Google auquel votre {source} est associé, et vos mesures arriveront par là.",
"needsCredentials": {
"title": "Cette instance n’a pas encore d’application {target} enregistrée.",
"detail": "Saisissez vos propres identifiants dans Paramètres → Intégrations, la connexion pourra alors démarrer."
diff --git a/messages/it.json b/messages/it.json
index 3eaba8330..a878cc00f 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5286,7 +5286,8 @@
},
"hints": {
"apple-health": "Sincronizzato dall'app iOS, oppure importato da un'esportazione.",
- "file": "Si importa nelle Impostazioni quando hai finito qui."
+ "file": "Si importa nelle Impostazioni quando hai finito qui.",
+ "fitbit": "Ora si collega tramite Google Health."
}
},
"visit": {
@@ -5340,6 +5341,7 @@
"waiting": "Non ancora collegato.",
"openSettings": "Apri Impostazioni → Integrazioni",
"noDate": "sconosciuto",
+ "throughGoogleHealth": "I dati {source} ora arrivano tramite {target}: la connessione classica a {source} è stata chiusa. Collega {target} con l'account Google a cui è associato il tuo {source} e le tue misurazioni arriveranno da lì.",
"needsCredentials": {
"title": "Questa istanza non ha ancora un’app {target} registrata.",
"detail": "Inserisci le tue credenziali in Impostazioni → Integrazioni e la connessione potrà partire."
diff --git a/messages/ko.json b/messages/ko.json
index 3c9c8da33..bcb8a8241 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -5286,7 +5286,8 @@
},
"hints": {
"apple-health": "iOS 앱이 동기화하거나 내보내기 파일에서 가져옵니다.",
- "file": "여기서 마친 뒤 설정에서 가져옵니다."
+ "file": "여기서 마친 뒤 설정에서 가져옵니다.",
+ "fitbit": "이제 Google Health를 통해 연결됩니다."
}
},
"visit": {
@@ -5340,6 +5341,7 @@
"waiting": "아직 연결되지 않았습니다.",
"openSettings": "설정 → 연동 열기",
"noDate": "알 수 없음",
+ "throughGoogleHealth": "{source} 데이터는 이제 {target}를 통해 들어옵니다. 기존 {source} 연결은 종료되었습니다. {source}에 로그인한 Google 계정으로 {target}를 연결하면 측정값이 그곳에서 들어옵니다.",
"needsCredentials": {
"title": "이 인스턴스에는 아직 {target} 앱이 등록되어 있지 않습니다.",
"detail": "설정 → 연동에서 직접 발급받은 인증 정보를 입력하면 연결을 시작할 수 있습니다."
diff --git a/messages/nl.json b/messages/nl.json
index 2baaa034f..6d81c2b6e 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -5286,7 +5286,8 @@
},
"hints": {
"apple-health": "Gesynchroniseerd door de iOS-app, of geïmporteerd uit een export.",
- "file": "Te importeren onder Instellingen zodra je hier klaar bent."
+ "file": "Te importeren onder Instellingen zodra je hier klaar bent.",
+ "fitbit": "Wordt nu gekoppeld via Google Health."
}
},
"visit": {
@@ -5340,6 +5341,7 @@
"waiting": "Nog niet gekoppeld.",
"openSettings": "Instellingen → Koppelingen openen",
"noDate": "onbekend",
+ "throughGoogleHealth": "{source}-gegevens komen nu binnen via {target}: de klassieke {source}-koppeling is stopgezet. Koppel {target} met het Google-account waarmee je {source} is aangemeld, dan komen je metingen daarvandaan.",
"needsCredentials": {
"title": "Deze instantie heeft nog geen {target}-app geregistreerd.",
"detail": "Voer je eigen inloggegevens in onder Instellingen → Koppelingen, daarna kan de koppeling starten."
diff --git a/messages/pl.json b/messages/pl.json
index ca998a9eb..b5d67a7dc 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5286,7 +5286,8 @@
},
"hints": {
"apple-health": "Synchronizowane przez aplikację iOS lub importowane z eksportu.",
- "file": "Import w Ustawieniach, gdy skończysz tutaj."
+ "file": "Import w Ustawieniach, gdy skończysz tutaj.",
+ "fitbit": "Teraz łączy się przez Google Health."
}
},
"visit": {
@@ -5340,6 +5341,7 @@
"waiting": "Jeszcze nie połączono.",
"openSettings": "Otwórz Ustawienia → Integracje",
"noDate": "nieznany",
+ "throughGoogleHealth": "Dane z {source} trafiają teraz przez {target}: klasyczne połączenie z {source} zostało wyłączone. Połącz {target} z kontem Google, na którym zalogowany jest Twój {source}, a pomiary zaczną napływać stamtąd.",
"needsCredentials": {
"title": "Ta instancja nie ma jeszcze zapisanej aplikacji {target}.",
"detail": "Wpisz własne dane dostępowe w Ustawieniach → Integracje, wtedy połączenie będzie mogło ruszyć."
diff --git a/messages/pt-BR.json b/messages/pt-BR.json
index 2c0220883..22ec53245 100644
--- a/messages/pt-BR.json
+++ b/messages/pt-BR.json
@@ -5286,7 +5286,8 @@
},
"hints": {
"apple-health": "Sincronizado pelo aplicativo iOS ou importado de uma exportação.",
- "file": "Importado nas Configurações quando terminar aqui."
+ "file": "Importado nas Configurações quando terminar aqui.",
+ "fitbit": "Agora é conectado pelo Google Health."
}
},
"visit": {
@@ -5340,6 +5341,7 @@
"waiting": "Ainda não conectado.",
"openSettings": "Abrir Configurações → Integrações",
"noDate": "desconhecido",
+ "throughGoogleHealth": "Os dados do {source} agora chegam pelo {target}: a conexão clássica com o {source} foi desativada. Conecte o {target} com a conta Google em que o seu {source} está conectado, e suas leituras chegam por lá.",
"needsCredentials": {
"title": "Esta instância ainda não tem um aplicativo {target} registrado.",
"detail": "Insira suas próprias credenciais em Configurações → Integrações, então a conexão pode começar."
diff --git a/messages/ru.json b/messages/ru.json
index 8e1669b48..bc55ce2a0 100644
--- a/messages/ru.json
+++ b/messages/ru.json
@@ -5286,7 +5286,8 @@
},
"hints": {
"apple-health": "Синхронизируется приложением iOS или импортируется из экспорта.",
- "file": "Импортируется в настройках, когда вы здесь закончите."
+ "file": "Импортируется в настройках, когда вы здесь закончите.",
+ "fitbit": "Теперь подключается через Google Health."
}
},
"visit": {
@@ -5340,6 +5341,7 @@
"waiting": "Пока не подключено.",
"openSettings": "Открыть Настройки → Интеграции",
"noDate": "неизвестно",
+ "throughGoogleHealth": "Данные {source} теперь поступают через {target}: классическое подключение {source} отключено. Подключите {target} с тем аккаунтом Google, в который выполнен вход на вашем {source}, и показания будут приходить оттуда.",
"needsCredentials": {
"title": "В этом экземпляре пока нет приложения {target}.",
"detail": "Введите свои учётные данные в разделе Настройки → Интеграции, и подключение сможет начаться."
diff --git a/src/components/onboarding/__tests__/first-result-screen.test.tsx b/src/components/onboarding/__tests__/first-result-screen.test.tsx
index f0941294e..5aeac181b 100644
--- a/src/components/onboarding/__tests__/first-result-screen.test.tsx
+++ b/src/components/onboarding/__tests__/first-result-screen.test.tsx
@@ -72,7 +72,7 @@ interface StatusOverrides {
lastSuccessAt?: string | null;
}
-function statuses(overrides: StatusOverrides | null) {
+function statuses(overrides: StatusOverrides | null, integration = "whoop") {
envelope.value =
overrides === null
? { isLoading: true, data: undefined }
@@ -82,7 +82,7 @@ function statuses(overrides: StatusOverrides | null) {
threshold: 3,
integrations: [
{
- integration: "whoop",
+ integration,
connected: overrides.connected ?? false,
configured: overrides.configured ?? true,
available: overrides.available,
@@ -121,8 +121,9 @@ function state(
function render(
overrides: StatusOverrides | null,
needs: Partial = {},
+ integration = "whoop",
) {
- statuses(overrides);
+ statuses(overrides, integration);
return renderToStaticMarkup(
@@ -241,3 +242,60 @@ describe(" — the connect arm", () => {
expect(html).toContain("Connect WHOOP");
});
});
+
+/**
+ * Fitbit's classic Web API was shut down on 2026-10-30 and Fitbit data now
+ * arrives through Google Health. The answer stays "Fitbit"; the task has to
+ * open, read and name the Google Health card, and say why.
+ */
+describe(" — a Fitbit answer", () => {
+ const fitbit = { sources: ["fitbit" as const] };
+
+ it("opens the Google Health card and says Fitbit data comes through it", () => {
+ const html = render({ connected: false }, fitbit, "google-health");
+ expect(html).toContain('data-connect-slot="connect"');
+ expect(html).toContain("Connect Google Health");
+ expect(html).toContain("/settings/integrations#google-health");
+ expect(html).not.toContain("/settings/integrations#fitbit");
+ expect(html).toContain('data-slot="onboarding-task-through"');
+ expect(html).toContain("Fitbit data now comes through Google Health");
+ });
+
+ it("reads the Google Health connection, not the classic Fitbit one", () => {
+ const delivering = render(
+ {
+ connected: true,
+ verdict: "fresh",
+ lastSuccessAt: "2026-11-01T06:00:00.000Z",
+ },
+ fitbit,
+ "google-health",
+ );
+ expect(delivering).toContain("Google Health is already connected");
+ expect(delivering).toContain("Fitbit data now comes through Google Health");
+
+ // A classic Fitbit row that still says connected is not the result.
+ const classic = render(
+ {
+ connected: true,
+ verdict: "fresh",
+ lastSuccessAt: "2026-10-29T06:00:00.000Z",
+ },
+ fitbit,
+ "fitbit",
+ );
+ expect(classic).not.toContain("is already connected");
+ expect(classic).not.toContain("data-connect-slot=");
+ });
+
+ it("names the Google Health app when the instance has none on file", () => {
+ const html = render(
+ { connected: false, configured: false },
+ fitbit,
+ "google-health",
+ );
+ expect(html).toContain('data-connect-slot="credentials"');
+ expect(html).toContain("no Google Health app on file yet");
+ expect(html).toContain("Fitbit data now comes through Google Health");
+ });
+});
diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx
index b03026969..e3ea4ae3a 100644
--- a/src/components/onboarding/first-result-screen.tsx
+++ b/src/components/onboarding/first-result-screen.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
@@ -36,6 +36,7 @@ import {
connectSourceView,
deliveringSources,
SOURCE_INTEGRATION,
+ sourceConnectionLabelKey,
type ConnectSourceSlot,
} from "@/lib/onboarding/first-result-config";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
@@ -166,7 +167,9 @@ function targetLabel(
): string {
if (!offer.target) return "";
if (offer.task === "connect-source") {
- return t(questionOptionLabelKey("sources", offer.target));
+ return isBrowserConnectableSource(offer.target)
+ ? t(sourceConnectionLabelKey(offer.target))
+ : t(questionOptionLabelKey("sources", offer.target));
}
return t(questionOptionLabelKey("areas", offer.target));
}
@@ -217,8 +220,32 @@ function ConnectSourceTask({
}) {
const { t } = useTranslations();
const fmt = useFormatters();
- const { anchor, statusKey } = SOURCE_INTEGRATION[source];
- const label = t(questionOptionLabelKey("sources", source));
+ const { anchor, statusKey, through } = SOURCE_INTEGRATION[source];
+ // The connection's name: the integration it goes through when the device's
+ // own card is gone (Fitbit through Google Health), else the answer itself.
+ const label = t(sourceConnectionLabelKey(source));
+ // Said on every tile, so a person who picked Fitbit is never left wondering
+ // why the step talks about Google Health.
+ const throughNote = through ? (
+
+ {t(through.noteKey, {
+ source: t(questionOptionLabelKey("sources", source)),
+ target: label,
+ })}
+
+ ) : null;
+ const withThroughNote = (tile: ReactNode): ReactNode =>
+ throughNote ? (
+
+ {throughNote}
+ {tile}
+
+ ) : (
+ tile
+ );
// Refetches on window focus, so coming back from the integrations tab
// after the handshake picks the fresh verdict up on its own.
const statuses = useIntegrationStatuses(true);
@@ -245,16 +272,16 @@ function ConnectSourceTask({
const settingsHref = `/settings/integrations#${anchor}`;
if (!view) {
- // Nothing is known yet. The heading is the person's own answer, so it
- // stays; the body says only that the check is running.
- return (
+ // Nothing is known yet. The heading names the connection, so it stays;
+ // the body says only that the check is running.
+ return withThroughNote(
{t("onboarding.flow.first-result.connect-source.checking")}
-
+
,
);
}
@@ -264,17 +291,17 @@ function ConnectSourceTask({
: t("onboarding.flow.first-result.connect-source.noDate");
if (view.slot === "result") {
- return (
+ return withThroughNote(
+ />,
);
}
if (view.slot === "attention") {
- return (
+ return withThroughNote(
+ />,
);
}
if (view.slot === "credentials") {
- return (
+ return withThroughNote(
+ />,
);
}
- return (
+ return withThroughNote(
{t("onboarding.flow.first-result.connect-source.waiting")}
-
+ ,
);
}
diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx
index aa37ccc41..e8bc4d4d0 100644
--- a/src/components/onboarding/getting-started-checklist.tsx
+++ b/src/components/onboarding/getting-started-checklist.tsx
@@ -39,7 +39,7 @@ import {
CHECKLIST_DISMISSED_ITEMS_KEY as DISMISSED_ITEMS_KEY,
CHECKLIST_EXPANDED_KEY as EXPANDED_KEY,
} from "@/lib/onboarding/checklist-storage";
-import { questionOptionLabelKey } from "@/lib/onboarding/question-config";
+import { sourceConnectionLabelKey } from "@/lib/onboarding/first-result-config";
import { isBrowserConnectableSource } from "@/lib/onboarding/wizard-steps";
const ITEM_ICONS: Record = {
@@ -547,9 +547,7 @@ export function GettingStartedChecklist() {
>
{item.id === "dataSource" && namedSource
? t("gettingStarted.items.dataSourceNamedTitle", {
- source: t(
- questionOptionLabelKey("sources", namedSource),
- ),
+ source: t(sourceConnectionLabelKey(namedSource)),
})
: t(labels.title)}
diff --git a/src/components/onboarding/question-screen.tsx b/src/components/onboarding/question-screen.tsx
index 69f44b1b0..35bb1e594 100644
--- a/src/components/onboarding/question-screen.tsx
+++ b/src/components/onboarding/question-screen.tsx
@@ -70,6 +70,7 @@ const OPTION_HINTS: Readonly> = {
"who.both": "onboarding.flow.who.hints.both",
"sources.apple-health": "onboarding.flow.sources.hints.apple-health",
"sources.file": "onboarding.flow.sources.hints.file",
+ "sources.fitbit": "onboarding.flow.sources.hints.fitbit",
};
export function QuestionScreen({
diff --git a/src/lib/onboarding/__tests__/first-result-config.test.ts b/src/lib/onboarding/__tests__/first-result-config.test.ts
index d05ee5f12..d018e3103 100644
--- a/src/lib/onboarding/__tests__/first-result-config.test.ts
+++ b/src/lib/onboarding/__tests__/first-result-config.test.ts
@@ -18,9 +18,25 @@ import {
connectSourceView,
deliveringSources,
SOURCE_INTEGRATION,
+ sourceConnectionLabelKey,
} from "../first-result-config";
import { ONBOARDING_SOURCE_KEYS } from "../needs";
import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps";
+const EN: unknown = JSON.parse(
+ readFileSync(
+ join(__dirname, "..", "..", "..", "..", "messages", "en.json"),
+ "utf8",
+ ),
+);
+
+function resolves(key: string): boolean {
+ let node: unknown = EN;
+ for (const part of key.split(".")) {
+ if (typeof node !== "object" || node === null) return false;
+ node = (node as Record)[part];
+ }
+ return typeof node === "string" && node.length > 0;
+}
/**
* Connections the setup flow deliberately does not ask about, each with its
@@ -29,8 +45,11 @@ import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps";
* it.
*/
const NOT_AN_ONBOARDING_SOURCE: Record = {
+ // Google Health carries Fitbit devices, so its question is the Fitbit
+ // answer: people name the device they own, not the transport. The test
+ // below holds that the Fitbit answer really does lead there.
"google-health":
- "the successor transport behind the Fitbit answer, which still links the classic Fitbit card",
+ "reached through the Fitbit answer, whose task opens the Google Health card",
dawarich:
"movement context for the environment module, not a source of readings",
};
@@ -62,6 +81,47 @@ describe("first-result targets", () => {
}
});
+ it("routes the Fitbit answer to Google Health, not the retired classic card", () => {
+ // Fitbit's classic Web API was shut down on 2026-10-30. The answer stays,
+ // because people still own Fitbit devices; the connection is Google
+ // Health's, on the person's own Google OAuth client.
+ expect(ONBOARDING_SOURCE_KEYS).toContain("fitbit");
+ expect(BROWSER_CONNECTABLE_SOURCES).toContain("fitbit");
+ expect(SOURCE_INTEGRATION.fitbit).toMatchObject({
+ anchor: "google-health",
+ statusKey: "google-health",
+ credentials: "byo",
+ });
+ expect(CONNECTION_KEYS as readonly string[]).toContain(
+ SOURCE_INTEGRATION.fitbit.statusKey,
+ );
+ expect(sourceConnectionLabelKey("fitbit")).toBe("settings.googleHealth");
+ expect(resolves(sourceConnectionLabelKey("fitbit"))).toBe(true);
+ expect(resolves(SOURCE_INTEGRATION.fitbit.through!.noteKey)).toBe(true);
+ // Every other source is named and opened by its own answer.
+ for (const source of BROWSER_CONNECTABLE_SOURCES) {
+ if (source === "fitbit") continue;
+ expect(SOURCE_INTEGRATION[source].through, source).toBeUndefined();
+ expect(sourceConnectionLabelKey(source)).toBe(
+ `onboarding.flow.sources.options.${source}`,
+ );
+ }
+ });
+
+ it("reads a Fitbit answer's delivery from Google Health", () => {
+ const entry = (integration: string) => ({
+ integration,
+ connected: true,
+ configured: true,
+ syncHealth: { verdict: "fresh" as const, since: null },
+ lastSuccessAt: null,
+ });
+ expect(deliveringSources([entry("google-health")]).has("fitbit")).toBe(
+ true,
+ );
+ expect(deliveringSources([entry("fitbit")]).has("fitbit")).toBe(false);
+ });
+
it("offers Garmin as a source the browser connects with a pasted key", () => {
expect(BROWSER_CONNECTABLE_SOURCES).toContain("garmin");
expect(SOURCE_INTEGRATION.garmin).toEqual({
diff --git a/src/lib/onboarding/first-result-config.ts b/src/lib/onboarding/first-result-config.ts
index 9c033b761..1a8f91ca7 100644
--- a/src/lib/onboarding/first-result-config.ts
+++ b/src/lib/onboarding/first-result-config.ts
@@ -10,6 +10,7 @@
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
import type { OnboardingAreaKey } from "@/lib/modules/registry";
+import { questionOptionLabelKey } from "./question-config";
import {
BROWSER_CONNECTABLE_SOURCES,
type BrowserConnectableSource,
@@ -66,16 +67,32 @@ export const AREA_PAGE_HREF: Readonly<
*/
export type SourceCredentialModel = "byo" | "shared" | "self-hosted";
+/**
+ * How a source is connected when that is not the source's own card. The
+ * answer stays the device the person owns; the connection is made, named and
+ * read back through another integration.
+ */
+export interface SourceConnectedThrough {
+ /** The i18n key of the integration the connection is named after. */
+ labelKey: string;
+ /** The i18n key of the sentence saying why it goes through there. */
+ noteKey: string;
+}
+
+export interface SourceIntegrationTarget {
+ anchor: string;
+ statusKey: string;
+ credentials: SourceCredentialModel;
+ through?: SourceConnectedThrough;
+}
+
/**
* The connections panel's anchor for each connectable source — the `id` of
* the provider card on Settings → Integrations, and the key its status is
* published under on `/api/integrations/status`.
*/
export const SOURCE_INTEGRATION: Readonly<
- Record<
- BrowserConnectableSource,
- { anchor: string; statusKey: string; credentials: SourceCredentialModel }
- >
+ Record
> = Object.freeze({
withings: { anchor: "withings", statusKey: "withings", credentials: "byo" },
oura: { anchor: "oura", statusKey: "oura", credentials: "shared" },
@@ -87,7 +104,20 @@ export const SOURCE_INTEGRATION: Readonly<
statusKey: "garmin",
credentials: "self-hosted",
},
- fitbit: { anchor: "fitbit", statusKey: "fitbit", credentials: "byo" },
+ // Fitbit's classic Web API was shut down on 2026-10-30; Fitbit data now
+ // arrives through Google Health. The answer stays "Fitbit", because that is
+ // the device, and the task opens, reads and names the Google Health card.
+ // Google Health runs on the person's own Google OAuth client.
+ fitbit: {
+ anchor: "google-health",
+ statusKey: "google-health",
+ credentials: "byo",
+ through: {
+ labelKey: "settings.googleHealth",
+ noteKey:
+ "onboarding.flow.first-result.connect-source.throughGoogleHealth",
+ },
+ },
strava: { anchor: "strava", statusKey: "strava", credentials: "shared" },
nightscout: {
anchor: "nightscout",
@@ -96,6 +126,19 @@ export const SOURCE_INTEGRATION: Readonly<
},
});
+/**
+ * The i18n key a source's connection is named with: the integration it goes
+ * through when there is one, otherwise the person's own answer.
+ */
+export function sourceConnectionLabelKey(
+ source: BrowserConnectableSource,
+): string {
+ return (
+ SOURCE_INTEGRATION[source].through?.labelKey ??
+ questionOptionLabelKey("sources", source)
+ );
+}
+
/** Which tile the connect step renders. */
export type ConnectSourceSlot =
/** Nothing is connected and something can be: today's connect card. */