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({