fix(onboarding): the connect step reads the sync verdict

The step decided "connected" from the ledger's `state` and had one piece of
copy for every outcome: "the first sync is in progress". That sentence was
false for a connection delivering for months, and worse for one the person
has to repair — the screen said everything was fine while the grant was gone.

`connectSourceView` resolves the envelope entry into one of four tiles and
says whether the flow may record its result. Only `pending_first_sync` and
`fresh` may; `stale` is acknowledged without being claimed as new; `stalled`,
`failing`, `reauth_required` and `parked` name the state and link at the page
that can change it. An unresolved envelope paints no tile at all.

A source the instance cannot connect — a BYO provider with no client pair, a
shared-app provider with no app — now points at the credentials instead of
offering a "Connect" button onto a card that has none.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 09:15:32 +02:00
1 parent 0bdbf009f6
commit da58d16642
11 files changed
+883 -45

No files matched your search

@@ -0,0 +1,194 @@
/**
* v1.38.19 (wave B / C1, C2, I1, I2, I3) — the connect arm of the one task
* the flow ends on.
*
* This arm is the only place in the flow that renders a claim about real
* account state, and it shipped without a test. It decided "connected" from
* the ledger's `state` field, whose "no row" default was `connected` — so a
* brand-new account that ticked WHOOP on Q4 was told WHOOP was connected and
* had the flow's one result stamped as achieved on its behalf. It also had a
* single piece of copy, "the first sync is in progress", for every outcome,
* including the two that need the person to act.
*
* Vitest renders without a DOM here (project convention: SSR only), so what
* is pinned is the first paint — which tile the screen paints for each of the
* eight verdicts and what it says. Whether the result is RECORDED is decided
* by `connectSourceView(...).settled` and pinned in
* `src/lib/onboarding/__tests__/first-result-config.test.ts`, where the eight
* verdicts are enumerated against the decision itself rather than its markup.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
import {
defaultOnboardingSteps,
emptyOnboardingNeeds,
type OnboardingStateDto,
} from "@/lib/onboarding/needs";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
vi.mock("../use-onboarding-flow", async () => {
const actual = await vi.importActual<typeof import("../use-onboarding-flow")>(
"../use-onboarding-flow",
);
return {
...actual,
useOnboardingAnswer: () => ({
mutateAsync: vi.fn().mockResolvedValue(undefined),
isPending: false,
}),
};
});
const envelope = vi.hoisted(() => ({
value: undefined as { isLoading: boolean; data: unknown } | undefined,
}));
vi.mock("@/components/settings/integrations/shared", async () => {
const actual = await vi.importActual<
typeof import("@/components/settings/integrations/shared")
>("@/components/settings/integrations/shared");
return {
...actual,
useIntegrationStatuses: () => envelope.value,
};
});
import { FirstResultScreen } from "../first-result-screen";
interface StatusOverrides {
connected?: boolean;
configured?: boolean;
available?: boolean;
state?: string;
verdict?: SyncVerdict;
since?: string | null;
lastSuccessAt?: string | null;
}
function statuses(overrides: StatusOverrides | null) {
envelope.value =
overrides === null
? { isLoading: true, data: undefined }
: {
isLoading: false,
data: {
threshold: 3,
integrations: [
{
integration: "whoop",
connected: overrides.connected ?? false,
configured: overrides.configured ?? true,
available: overrides.available,
state: overrides.state ?? "unknown",
lastSuccessAt: overrides.lastSuccessAt ?? null,
lastAttemptAt: null,
lastError: null,
consecutiveFailuresByKind: null,
syncHealth: {
verdict: overrides.verdict ?? "disconnected",
since: overrides.since ?? null,
},
metricFreshness: [],
},
],
},
};
}
function state(): OnboardingStateDto {
return {
steps: defaultOnboardingSteps(),
needs: {
...emptyOnboardingNeeds(),
recordTarget: "me",
sources: ["whoop"],
},
completedAt: null,
firstResult: null,
};
}
function render(overrides: StatusOverrides | null) {
statuses(overrides);
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<FirstResultScreen state={state()} />
</I18nProvider>,
);
}
const VERDICT_SLOT = [
["disconnected", "connect"],
["pending_first_sync", "result"],
["fresh", "result"],
["stale", "result"],
["stalled", "attention"],
["failing", "attention"],
["reauth_required", "attention"],
["parked", "attention"],
] as const;
describe("<FirstResultScreen> — the connect arm", () => {
it.each(VERDICT_SLOT)(
"paints the %s verdict on the %s tile",
(verdict, slot) => {
const html = render({
connected: verdict !== "disconnected",
verdict,
since: "2026-09-01T00:00:00.000Z",
lastSuccessAt: "2026-09-01T00:00:00.000Z",
});
expect(html).toContain(`data-connect-slot="${slot}"`);
},
);
it("never claims a connection for a fresh account's empty ledger", () => {
// The exact envelope a brand-new account publishes: no row for the
// provider, so the ledger state is the synthetic `unknown`.
const html = render({ connected: false, state: "unknown" });
expect(html).toContain('data-connect-slot="connect"');
expect(html).not.toContain("is connected");
expect(html).not.toContain("The first sync is in progress");
});
it("says the first sync is running only while it actually is", () => {
expect(
render({ connected: true, verdict: "pending_first_sync" }),
).toContain("The first sync is in progress");
expect(
render({
connected: true,
verdict: "fresh",
lastSuccessAt: "2026-09-01T00:00:00.000Z",
}),
).not.toContain("The first sync is in progress");
});
it("names the state a connection that needs repairing is in", () => {
const html = render({
connected: true,
verdict: "reauth_required",
since: "2026-09-01T00:00:00.000Z",
});
expect(html).toContain("needs renewing");
expect(html).toContain("/settings/integrations#whoop");
});
it("points a source the instance cannot connect at the credentials", () => {
const html = render({ connected: false, configured: false });
expect(html).toContain('data-connect-slot="credentials"');
expect(html).toContain("no WHOOP app on file yet");
});
it("decides nothing while the status envelope is still in flight", () => {
const html = render(null);
expect(html).not.toContain("data-connect-slot=");
expect(html).toContain('data-slot="onboarding-task-checking"');
});
});
+128 -16
View File
@@ -33,7 +33,9 @@ import type { OnboardingAreaKey } from "@/lib/modules/registry";
import {
AREA_PAGE_HREF,
AREA_READING_TARGETS,
connectSourceView,
SOURCE_INTEGRATION,
type ConnectSourceSlot,
} from "@/lib/onboarding/first-result-config";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
import { questionOptionLabelKey } from "@/lib/onboarding/question-config";
@@ -193,32 +195,92 @@ function ConnectSourceTask({
onComplete: (target: string | null) => Promise<void>;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { anchor, statusKey } = SOURCE_INTEGRATION[source];
const label = t(questionOptionLabelKey("sources", source));
// 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);
const status = pickStatus(statuses.data, statusKey as IntegrationKey);
const connected = status?.connected === true || status?.state === "connected";
// The liveness truth is the server-resolved verdict and nothing else. The
// ledger's `state` is what the last ATTEMPT did, and its "no row" default
// read as `connected` until v1.38.19 — which is how a brand-new account was
// told its wearable was connected, and had this step stamped as achieved on
// its behalf. `null` means the envelope has not resolved: no claim, no tile.
const view = connectSourceView(source, status);
const settled = view?.settled ?? false;
// Record the result once the connection is there — once. `completed` flips
// only after the write resolves, and the status envelope can re-render this
// in between, so a ref latches the first call.
// Record the result once the connection is really there — once. `completed`
// flips only after the write resolves, and the status envelope can re-render
// this in between, so a ref latches the first call.
const recorded = useRef(completed);
useEffect(() => {
if (connected && !recorded.current) {
if (settled && !recorded.current) {
recorded.current = true;
void onComplete(source);
}
}, [connected, onComplete, source]);
}, [settled, onComplete, source]);
if (completed || connected) {
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 (
<p
className="text-muted-foreground text-sm"
role="status"
data-slot="onboarding-task-checking"
>
{t("onboarding.flow.first-result.connect-source.checking")}
</p>
);
}
const key = `onboarding.flow.first-result.connect-source.state.${view.verdict}`;
const when = view.when
? fmt.dateTime(view.when)
: t("onboarding.flow.first-result.connect-source.noDate");
if (view.slot === "result") {
return (
<ResultTile
title={t("onboarding.flow.first-result.connect-source.done", {
target: label,
})}
detail={t("onboarding.flow.first-result.connect-source.syncing")}
title={t(`${key}.title`, { target: label })}
detail={t(`${key}.detail`, { when })}
connect={{ slot: view.slot, state: view.verdict }}
/>
);
}
if (view.slot === "attention") {
return (
<ConnectNoticeTile
slot={view.slot}
state={view.verdict}
testSlot="onboarding-task-attention"
title={t(`${key}.title`, { target: label })}
detail={t(`${key}.detail`, { when })}
href={settingsHref}
cta={t("onboarding.flow.first-result.connect-source.openSettings")}
/>
);
}
if (view.slot === "credentials") {
return (
<ConnectNoticeTile
slot={view.slot}
state={view.verdict}
testSlot="onboarding-task-credentials"
title={t(
"onboarding.flow.first-result.connect-source.needsCredentials.title",
{ target: label },
)}
detail={t(
"onboarding.flow.first-result.connect-source.needsCredentials.detail",
)}
href={settingsHref}
cta={t("onboarding.flow.first-result.connect-source.openSettings")}
/>
);
}
@@ -227,6 +289,8 @@ function ConnectSourceTask({
<div
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
data-slot="onboarding-task-connect"
data-connect-slot={view.slot}
data-connect-state={view.verdict}
>
<p className="text-sm">
{t("onboarding.flow.first-result.connect-source.howTo", {
@@ -234,21 +298,58 @@ function ConnectSourceTask({
})}
</p>
<Button asChild className="min-h-11">
<Link href={`/settings/integrations#${anchor}`}>
<Link href={settingsHref}>
{t("onboarding.flow.first-result.connect-source.cta", {
target: label,
})}
</Link>
</Button>
<p className="text-muted-foreground text-xs" aria-live="polite">
{statuses.isLoading
? t("onboarding.flow.first-result.connect-source.checking")
: t("onboarding.flow.first-result.connect-source.waiting")}
{t("onboarding.flow.first-result.connect-source.waiting")}
</p>
</div>
);
}
/**
* A connection the flow cannot treat as its result: one that needs repairing
* (`attention`) or one the instance cannot start at all (`credentials`). Both
* say which state it is in and link at the one page that can change it —
* never a "Connect" button onto a card that has none.
*/
function ConnectNoticeTile({
slot,
state,
testSlot,
title,
detail,
href,
cta,
}: {
slot: ConnectSourceSlot;
state: string;
testSlot: string;
title: string;
detail: string;
href: string;
cta: string;
}) {
return (
<div
className="bg-card border-border space-y-3 rounded-xl border p-4 md:p-6"
data-slot={testSlot}
data-connect-slot={slot}
data-connect-state={state}
>
<p className="text-sm font-medium">{title}</p>
<p className="text-muted-foreground text-sm">{detail}</p>
<Button asChild variant="outline" className="min-h-11">
<Link href={href}>{cta}</Link>
</Button>
</div>
);
}
/* ── add a medication ──────────────────────────────────────────────────── */
interface MedicationDetail {
@@ -508,11 +609,22 @@ function LatestReadingTile({
* itself sits under it in content colour, because it is the reading, the
* medication or the connection the person came for.
*/
function ResultTile({ title, detail }: { title: string; detail?: string }) {
function ResultTile({
title,
detail,
connect,
}: {
title: string;
detail?: string;
/** Set by the connect arm so a journey can read which verdict it painted. */
connect?: { slot: ConnectSourceSlot; state: string };
}) {
return (
<div
className="bg-card border-border space-y-2 rounded-xl border p-4 md:p-6"
data-slot="onboarding-first-result-done"
data-connect-slot={connect?.slot}
data-connect-state={connect?.state}
>
<WrittenOutcomeLine
outcome="success"