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

@@ -3,6 +3,9 @@
* on a surface that exists, so the first-result screen can never be handed
* a task it cannot render.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry";
@@ -11,6 +14,7 @@ import { NAV_DESTINATIONS } from "@/components/layout/nav-model";
import {
AREA_PAGE_HREF,
AREA_READING_TARGETS,
connectSourceView,
SOURCE_INTEGRATION,
} from "../first-result-config";
import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps";
@@ -38,3 +42,180 @@ describe("first-result targets", () => {
}
});
});
/**
* v1.38.19 (wave B / C1, I2, I3) — what the connect step is allowed to say.
*
* The screen used to decide "connected" from the ledger's `state` field, whose
* default meant the opposite of its name, and had one piece of copy for every
* outcome. The decision is a pure function over the envelope entry now, so the
* eight verdicts can be enumerated here rather than inferred from markup.
*/
describe("connectSourceView", () => {
const cases = [
["disconnected", "connect", false],
["pending_first_sync", "result", true],
["fresh", "result", true],
["stale", "result", false],
["stalled", "attention", false],
["failing", "attention", false],
["reauth_required", "attention", false],
["parked", "attention", false],
] as const;
it.each(cases)(
"verdict %s renders the %s tile and settles=%s",
(verdict, slot, settled) => {
const view = connectSourceView("whoop", {
connected: verdict !== "disconnected",
configured: true,
syncHealth: { verdict, since: "2026-09-01T00:00:00.000Z" },
lastSuccessAt: "2026-09-01T00:00:00.000Z",
});
expect(view?.slot).toBe(slot);
expect(view?.settled).toBe(settled);
expect(view?.verdict).toBe(verdict);
},
);
it("says nothing at all until the envelope has resolved", () => {
expect(connectSourceView("whoop", undefined)).toBeNull();
});
it("does not read a fresh account's empty ledger as a connection", () => {
// What a brand-new account publishes: no row anywhere, so `state` is the
// synthetic `unknown` and the verdict is `disconnected`.
const view = connectSourceView("whoop", {
connected: false,
configured: true,
state: "unknown",
syncHealth: { verdict: "disconnected", since: null },
lastSuccessAt: null,
});
expect(view?.slot).toBe("connect");
expect(view?.settled).toBe(false);
});
it("points a BYO provider with no credentials at the credentials", () => {
const view = connectSourceView("whoop", {
connected: false,
configured: false,
syncHealth: { verdict: "disconnected", since: null },
lastSuccessAt: null,
});
expect(view?.slot).toBe("credentials");
});
it("points a shared-app provider with no usable app at the credentials", () => {
const view = connectSourceView("oura", {
connected: false,
configured: false,
available: false,
syncHealth: { verdict: "disconnected", since: null },
lastSuccessAt: null,
});
expect(view?.slot).toBe("credentials");
});
it("offers the connect card when the instance's shared app resolves", () => {
const view = connectSourceView("oura", {
connected: false,
configured: false,
available: true,
syncHealth: { verdict: "disconnected", since: null },
lastSuccessAt: null,
});
expect(view?.slot).toBe("connect");
});
it("never withholds the connect card from a self-hosted URL source", () => {
const view = connectSourceView("nightscout", {
connected: false,
configured: false,
syncHealth: { verdict: "disconnected", since: null },
lastSuccessAt: null,
});
expect(view?.slot).toBe("connect");
});
it("dates a stale tile from the last value and a failing one from the streak", () => {
expect(
connectSourceView("whoop", {
connected: true,
configured: true,
syncHealth: { verdict: "stale", since: "2026-08-01T00:00:00.000Z" },
lastSuccessAt: "2026-08-20T00:00:00.000Z",
})?.when,
).toBe("2026-08-20T00:00:00.000Z");
expect(
connectSourceView("whoop", {
connected: true,
configured: true,
syncHealth: { verdict: "failing", since: "2026-08-01T00:00:00.000Z" },
lastSuccessAt: null,
})?.when,
).toBe("2026-08-01T00:00:00.000Z");
});
});
/**
* The connect step's copy is chosen by a key built at runtime from the
* verdict, which the repo's three i18n guards cannot see into: a template
* `ns.${x}` counts as covering its whole subtree, so a MEMBER missing from
* every bundle is invisible to them. Enumerate the verdicts here instead —
* the eight the server can resolve, in all seven locales.
*/
describe("connect-source state copy", () => {
const VERDICTS = [
"fresh",
"stale",
"stalled",
"failing",
"reauth_required",
"parked",
"pending_first_sync",
] as const;
const LOCALES = readdirSync(join(__dirname, "../../../../messages"))
.filter((file) => file.endsWith(".json"))
.map((file) => ({
locale: file.replace(/\.json$/, ""),
copy: JSON.parse(
readFileSync(join(__dirname, "../../../../messages", file), "utf8"),
) as Record<string, never>,
}));
it("covers all seven locales", () => {
expect(LOCALES.length).toBe(7);
});
it.each(LOCALES)(
"names every verdict in $locale, with no English fallback",
({ locale, copy }) => {
const block = (
copy as unknown as {
onboarding: {
flow: {
"first-result": {
"connect-source": Record<string, Record<string, unknown>>;
};
};
};
}
).onboarding.flow["first-result"]["connect-source"];
for (const verdict of VERDICTS) {
const entry = block.state[verdict] as
{ title?: string; detail?: string } | undefined;
expect(entry?.title, `${locale}/${verdict}.title`).toBeTruthy();
expect(entry?.detail, `${locale}/${verdict}.detail`).toBeTruthy();
}
// `disconnected` is the one verdict with no state copy: it renders the
// connect card or the credentials note, neither of which is a claim
// about a connection that exists.
expect(block.state.disconnected).toBeUndefined();
expect(block.needsCredentials.title).toBeTruthy();
expect(block.openSettings).toBeTruthy();
expect(block.noDate).toBeTruthy();
},
);
});
+121 -8
View File
@@ -7,6 +7,7 @@
* which integration a Q4 source is, and which page carries an area the
* measurement form cannot log.
*/
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
import type { OnboardingAreaKey } from "@/lib/modules/registry";
import type { BrowserConnectableSource } from "./wizard-steps";
@@ -47,19 +48,131 @@ export const AREA_PAGE_HREF: Readonly<
illness: "/illness",
});
/**
* How a source's connection is authorised on this instance, which decides
* what "cannot be connected yet" means for it:
*
* - `byo` — the OAuth app is per-user only (`configured` is the stored client
* id/secret pair). With none, the provider card renders a credentials note
* and no connect button at all, so offering "Connect" walks the person into
* a dead end.
* - `shared` — an operator env app is the fallback (`available` says whether
* any usable credentials resolve, own or shared).
* - `self-hosted` — a URL the person pastes; nothing can be missing up front.
*/
export type SourceCredentialModel = "byo" | "shared" | "self-hosted";
/**
* 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 }>
Record<
BrowserConnectableSource,
{ anchor: string; statusKey: string; credentials: SourceCredentialModel }
>
> = Object.freeze({
withings: { anchor: "withings", statusKey: "withings" },
oura: { anchor: "oura", statusKey: "oura" },
whoop: { anchor: "whoop", statusKey: "whoop" },
polar: { anchor: "polar", statusKey: "polar" },
fitbit: { anchor: "fitbit", statusKey: "fitbit" },
strava: { anchor: "strava", statusKey: "strava" },
nightscout: { anchor: "nightscout", statusKey: "nightscout" },
withings: { anchor: "withings", statusKey: "withings", credentials: "byo" },
oura: { anchor: "oura", statusKey: "oura", credentials: "shared" },
whoop: { anchor: "whoop", statusKey: "whoop", credentials: "byo" },
polar: { anchor: "polar", statusKey: "polar", credentials: "shared" },
fitbit: { anchor: "fitbit", statusKey: "fitbit", credentials: "byo" },
strava: { anchor: "strava", statusKey: "strava", credentials: "shared" },
nightscout: {
anchor: "nightscout",
statusKey: "nightscout",
credentials: "self-hosted",
},
});
/** Which tile the connect step renders. */
export type ConnectSourceSlot =
/** Nothing is connected and something can be: today's connect card. */
| "connect"
/** Nothing is connected and nothing CAN be: point at the credentials. */
| "credentials"
/** A working connection — the flow's result, whether or not it is new. */
| "result"
/** A connection the person has to repair, named rather than painted green. */
| "attention";
/** The fields of an `/api/integrations/status` entry this decision reads. */
export interface ConnectSourceStatus {
connected?: boolean;
configured?: boolean;
available?: boolean;
state?: string;
syncHealth?: { verdict: SyncVerdict; since: string | null };
lastSuccessAt?: string | null;
}
export interface ConnectSourceView {
slot: ConnectSourceSlot;
verdict: SyncVerdict;
/**
* Whether the flow may record this as the completed first result. Only a
* connection that is delivering, or one whose first sync is under way —
* never a connection that needs repairing, and never the mere absence of a
* ledger row.
*/
settled: boolean;
/** The instant the copy names, or null when there is none to name. */
when: string | null;
}
/**
* What the connect step may honestly say about one source.
*
* The liveness truth is `syncHealth.verdict` and nothing else — the ledger's
* `state` is what the last ATTEMPT did, and its "no row" default used to read
* as `connected`, which is how a brand-new account was told its wearable was
* connected and had the flow's one result stamped as achieved on its behalf.
*
* Returns `null` while the envelope has not resolved: an unloaded status is
* not evidence of anything, and the screen says nothing rather than guessing.
*/
export function connectSourceView(
source: BrowserConnectableSource,
status: ConnectSourceStatus | undefined,
): ConnectSourceView | null {
if (!status) return null;
const verdict = status.syncHealth?.verdict ?? "disconnected";
const settled = verdict === "pending_first_sync" || verdict === "fresh";
const when =
verdict === "fresh" || verdict === "stale"
? (status.lastSuccessAt ?? status.syncHealth?.since ?? null)
: (status.syncHealth?.since ?? status.lastSuccessAt ?? null);
if (settled || verdict === "stale") {
return { slot: "result", verdict, settled, when };
}
if (verdict !== "disconnected") {
return { slot: "attention", verdict, settled, when };
}
return {
slot: canStartConnection(source, status) ? "connect" : "credentials",
verdict,
settled,
when,
};
}
/**
* Whether the connect CTA leads anywhere. A `byo` provider without its client
* pair and a `shared` provider without any resolvable app both land on a card
* that has no connect button — the dead end the flow used to walk into.
*/
function canStartConnection(
source: BrowserConnectableSource,
status: ConnectSourceStatus,
): boolean {
switch (SOURCE_INTEGRATION[source].credentials) {
case "byo":
return status.configured !== false;
case "shared":
return status.available !== false;
case "self-hosted":
return true;
}
}