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. */