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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 23:59:18 +01:00
1 parent 341061de5c
commit e9ae024035
17 files changed
+252 -39

No files matched your search

@@ -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<OnboardingStateDto["needs"]> = {},
integration = "whoop",
) {
statuses(overrides);
statuses(overrides, integration);
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
@@ -241,3 +242,60 @@ describe("<FirstResultScreen> — 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("<FirstResultScreen> — 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");
});
});
@@ -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 ? (
<p
className="text-muted-foreground text-sm"
data-slot="onboarding-task-through"
>
{t(through.noteKey, {
source: t(questionOptionLabelKey("sources", source)),
target: label,
})}
</p>
) : null;
const withThroughNote = (tile: ReactNode): ReactNode =>
throughNote ? (
<div className="space-y-4">
{throughNote}
{tile}
</div>
) : (
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(
<p
className="text-muted-foreground text-sm"
role="status"
data-slot="onboarding-task-checking"
>
{t("onboarding.flow.first-result.connect-source.checking")}
</p>
</p>,
);
}
@@ -264,17 +291,17 @@ function ConnectSourceTask({
: t("onboarding.flow.first-result.connect-source.noDate");
if (view.slot === "result") {
return (
return withThroughNote(
<ResultTile
title={t(`${key}.title`, { target: label })}
detail={t(`${key}.detail`, { when })}
connect={{ slot: view.slot, state: view.verdict }}
/>
/>,
);
}
if (view.slot === "attention") {
return (
return withThroughNote(
<ConnectNoticeTile
slot={view.slot}
state={view.verdict}
@@ -283,12 +310,12 @@ function ConnectSourceTask({
detail={t(`${key}.detail`, { when })}
href={settingsHref}
cta={t("onboarding.flow.first-result.connect-source.openSettings")}
/>
/>,
);
}
if (view.slot === "credentials") {
return (
return withThroughNote(
<ConnectNoticeTile
slot={view.slot}
state={view.verdict}
@@ -302,11 +329,11 @@ function ConnectSourceTask({
)}
href={settingsHref}
cta={t("onboarding.flow.first-result.connect-source.openSettings")}
/>
/>,
);
}
return (
return withThroughNote(
<div
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
data-slot="onboarding-task-connect"
@@ -328,7 +355,7 @@ function ConnectSourceTask({
<p className="text-muted-foreground text-xs" aria-live="polite">
{t("onboarding.flow.first-result.connect-source.waiting")}
</p>
</div>
</div>,
);
}
@@ -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<ChecklistItemId, LucideIcon> = {
@@ -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)}
</p>
@@ -70,6 +70,7 @@ const OPTION_HINTS: Readonly<Record<string, string>> = {
"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({
@@ -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<string, unknown>)[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<string, string> = {
// 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({
+48 -5
View File
@@ -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<BrowserConnectableSource, SourceIntegrationTarget>
> = 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. */