mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Merge wave B — the connect step reads liveness, not a latched field
A provider with no sync history reports "unknown" instead of "connected", so a fresh account is no longer told a device it never linked is already delivering; the connect step resolves the sync verdict into four honest tiles, records the flow's result only for a connection that is actually serving, points an unconfigured provider at its credentials, and steps over a source the account already has.
This commit is contained in:
commit
4d7b326fda
25 files changed
+1440
-73
No files matched your search
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
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(
|
||||
needs: Partial<OnboardingStateDto["needs"]> = {},
|
||||
): OnboardingStateDto {
|
||||
return {
|
||||
steps: defaultOnboardingSteps(),
|
||||
needs: {
|
||||
...emptyOnboardingNeeds(),
|
||||
recordTarget: "me",
|
||||
sources: ["whoop"],
|
||||
...needs,
|
||||
},
|
||||
completedAt: null,
|
||||
firstResult: null,
|
||||
};
|
||||
}
|
||||
|
||||
function render(
|
||||
overrides: StatusOverrides | null,
|
||||
needs: Partial<OnboardingStateDto["needs"]> = {},
|
||||
) {
|
||||
statuses(overrides);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<FirstResultScreen state={state(needs)} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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"');
|
||||
});
|
||||
|
||||
/**
|
||||
* 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";
|
||||
@@ -33,7 +33,10 @@ import type { OnboardingAreaKey } from "@/lib/modules/registry";
|
||||
import {
|
||||
AREA_PAGE_HREF,
|
||||
AREA_READING_TARGETS,
|
||||
connectSourceView,
|
||||
deliveringSources,
|
||||
SOURCE_INTEGRATION,
|
||||
type ConnectSourceSlot,
|
||||
} from "@/lib/onboarding/first-result-config";
|
||||
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
|
||||
import { questionOptionLabelKey } from "@/lib/onboarding/question-config";
|
||||
@@ -68,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
|
||||
@@ -193,32 +212,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 +306,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 +315,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 +626,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"
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
IntegrationErrorMessage,
|
||||
pillFailurePropsFor,
|
||||
pillStateForVerdict,
|
||||
type IntegrationState,
|
||||
} from "./shared";
|
||||
import {
|
||||
CallbackMismatchNotice,
|
||||
@@ -78,12 +79,12 @@ export interface OAuthProviderStatus {
|
||||
available: boolean;
|
||||
/** Whether the user has stored their own BYO client id/secret pair. */
|
||||
hasOwnCredentials?: boolean;
|
||||
state?:
|
||||
| "connected"
|
||||
| "error_transient"
|
||||
| "error_reauth"
|
||||
| "disconnected"
|
||||
| "parked";
|
||||
/**
|
||||
* The raw ledger state, carried for shape parity with the envelope. The
|
||||
* card paints from `syncHealth.verdict`, never from this — which is why
|
||||
* v1.38.19's `unknown` member needs no arm here.
|
||||
*/
|
||||
state?: IntegrationState;
|
||||
lastSuccessAt?: string | null;
|
||||
lastAttemptAt?: string | null;
|
||||
lastError?: string | null;
|
||||
|
||||
@@ -36,8 +36,16 @@ export type IntegrationKey =
|
||||
// Nightscout folds onto the envelope so the card stops fetching its own
|
||||
// status and inherits the shared verdict.
|
||||
| "nightscout";
|
||||
/** Mirrors `IntegrationState` in `src/lib/integrations/status.ts`. */
|
||||
export type IntegrationState =
|
||||
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
|
||||
| "connected"
|
||||
| "error_transient"
|
||||
| "error_reauth"
|
||||
| "disconnected"
|
||||
| "parked"
|
||||
// v1.38.19 — no ledger row: the ledger has no history for this provider.
|
||||
// Read like `disconnected`; `syncHealth.verdict` owns liveness.
|
||||
| "unknown";
|
||||
|
||||
export interface IntegrationStatusViewModel {
|
||||
integration: IntegrationKey;
|
||||
|
||||
Reference in new issue
Block a user