fix(onboarding): a connected source is acknowledged, not offered

`chooseFirstResultTask` read the answers alone, so an account whose WHOOP had
been feeding the dashboard for months was sent to go and connect WHOOP. It
takes the delivering sources now; the server still picks from the answers,
having no status in hand, and the screen re-picks once the envelope resolves.

The fall-through stops where there is nowhere to fall to: a person whose one
answer is an already-connected source keeps the step and is told what was
found, rather than ending the flow on no result at all.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 09:22:18 +02:00
1 parent da58d16642
commit 8d81ac0708
6 files changed
+194 -9

No files matched your search

@@ -19,6 +19,7 @@
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
@@ -101,25 +102,33 @@ function statuses(overrides: StatusOverrides | null) {
};
}
function state(): OnboardingStateDto {
function state(
needs: Partial<OnboardingStateDto["needs"]> = {},
): OnboardingStateDto {
return {
steps: defaultOnboardingSteps(),
needs: {
...emptyOnboardingNeeds(),
recordTarget: "me",
sources: ["whoop"],
...needs,
},
completedAt: null,
firstResult: null,
};
}
function render(overrides: StatusOverrides | null) {
function render(
overrides: StatusOverrides | null,
needs: Partial<OnboardingStateDto["needs"]> = {},
) {
statuses(overrides);
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<FirstResultScreen state={state()} />
</I18nProvider>,
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<FirstResultScreen state={state(needs)} />
</I18nProvider>
</QueryClientProvider>,
);
}
@@ -191,4 +200,44 @@ describe("<FirstResultScreen> — the connect arm", () => {
expect(html).not.toContain("data-connect-slot=");
expect(html).toContain('data-slot="onboarding-task-checking"');
});
/**
* v1.38.19 (wave B / I1) — the heading is chosen from the answers alone on
* the server, because no status is in hand there. Once the envelope has
* resolved the screen re-picks: a source that is already delivering is not
* a task, and "Connect WHOOP" over a WHOOP that has been feeding the
* dashboard for months is the flow talking past the account it can see.
*/
it("stops offering a source that is already delivering", () => {
const html = render(
{
connected: true,
verdict: "fresh",
lastSuccessAt: "2026-09-10T06:35:00.000Z",
},
{ medication: "yes" },
);
expect(html).toContain('data-task="add-medication"');
expect(html).not.toContain("Connect WHOOP");
});
it("acknowledges the connection when there is nothing to fall through to", () => {
// The whole answer is one already-connected source. Dropping the step
// would end the flow on nothing at all, so the screen keeps it and says
// what it found — and that is the result it records.
const html = render({
connected: true,
verdict: "fresh",
lastSuccessAt: "2026-09-10T06:35:00.000Z",
});
expect(html).toContain('data-task="connect-source"');
expect(html).toContain('data-connect-state="fresh"');
expect(html).toContain("WHOOP is already connected");
});
it("keeps the answer's heading while the envelope is in flight", () => {
const html = render(null, { medication: "yes" });
expect(html).toContain('data-task="connect-source"');
expect(html).toContain("Connect WHOOP");
});
});
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
@@ -34,6 +34,7 @@ import {
AREA_PAGE_HREF,
AREA_READING_TARGETS,
connectSourceView,
deliveringSources,
SOURCE_INTEGRATION,
type ConnectSourceSlot,
} from "@/lib/onboarding/first-result-config";
@@ -70,7 +71,23 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) {
const { t } = useTranslations();
const router = useRouter();
const answer = useOnboardingAnswer();
const offer = chooseFirstResultTask(state.needs);
// The server picked the order from the answers alone — it had no status in
// hand. Re-pick here once the envelope resolves: a source that is already
// delivering is not a task, and offering to connect it is the flow talking
// past an account it can see. The same query key the task body reads, so
// this costs no second request.
const statuses = useIntegrationStatuses(true);
const delivering = useMemo(
() => deliveringSources(statuses.data?.integrations),
[statuses.data],
);
// …but only where there is somewhere to fall to. A person whose one answer
// is a source that is already delivering still gets an end to the flow: the
// screen acknowledges the connection it found rather than dropping the step
// and ending on nothing.
const offer =
chooseFirstResultTask(state.needs, delivering) ??
chooseFirstResultTask(state.needs);
const back = previousScreen(state, "first-result");
// Completed only when the LEDGER says so as well: a restart puts the step