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

+37 -3
View File
@@ -4677,12 +4677,46 @@
"connect-source": {
"title": "{target} verbinden",
"body": "Eine Verbindung, aus deinen Antworten gewählt. Danach kommen die Werte von selbst.",
"howTo": "Die Verbindung wird auf der Integrationsseite hergestellt. Komm danach hierher zurück; dieser Bildschirm merkt, wenn sie steht.",
"howTo": "Die Verbindung zu {target} wird auf der Integrationsseite hergestellt. Komm danach hierher zurück; dieser Bildschirm merkt, wenn sie steht.",
"cta": "{target} verbinden",
"checking": "Verbindung wird geprüft.",
"waiting": "Noch nicht verbunden.",
"done": "{target} ist verbunden.",
"syncing": "Die erste Synchronisierung läuft. Die Werte erscheinen auf dem Dashboard, sobald sie ankommen."
"openSettings": "Einstellungen → Integrationen öffnen",
"noDate": "unbekannt",
"needsCredentials": {
"title": "Diese Instanz hat noch keine {target}-App hinterlegt.",
"detail": "Trage deine eigenen Zugangsdaten unter Einstellungen → Integrationen ein, dann kann die Verbindung starten."
},
"state": {
"pending_first_sync": {
"title": "{target} ist verbunden.",
"detail": "Die erste Synchronisierung läuft. Die Werte erscheinen auf dem Dashboard, sobald sie ankommen."
},
"fresh": {
"title": "{target} ist schon verbunden.",
"detail": "Letzter Wert {when}. Hier gibt es nichts mehr zu tun."
},
"stale": {
"title": "{target} ist verbunden, aber es kommt gerade nichts an.",
"detail": "Letzter Wert {when}. Die Verbindung selbst ist in Ordnung."
},
"stalled": {
"title": "{target} ist verbunden, aber es wird nicht mehr synchronisiert.",
"detail": "Letzter Versuch {when}. Prüfe die Verbindung unter Einstellungen → Integrationen."
},
"failing": {
"title": "{target} ist verbunden, aber die Synchronisierung schlägt fehl.",
"detail": "Ohne Erfolg seit {when}. Prüfe die Verbindung unter Einstellungen → Integrationen."
},
"reauth_required": {
"title": "Die Verbindung zu {target} muss erneuert werden.",
"detail": "Einmal neu anmelden, dann laufen die Werte weiter."
},
"parked": {
"title": "Die Verbindung zu {target} ist pausiert.",
"detail": "Prüfe die Verbindung unter Einstellungen → Integrationen."
}
}
},
"add-medication": {
"title": "Dein erstes Medikament hinzufügen",
+37 -3
View File
@@ -4677,12 +4677,46 @@
"connect-source": {
"title": "Connect {target}",
"body": "One connection, chosen from your answers. Readings then arrive on their own.",
"howTo": "The connection is made on the integrations page. Come back here afterwards; this screen notices when it is done.",
"howTo": "The connection to {target} is made on the integrations page. Come back here afterwards; this screen notices when it is done.",
"cta": "Connect {target}",
"checking": "Checking the connection.",
"waiting": "Not connected yet.",
"done": "{target} is connected.",
"syncing": "The first sync is in progress. Readings appear on the dashboard as they arrive."
"openSettings": "Open Settings → Integrations",
"noDate": "unknown",
"needsCredentials": {
"title": "This instance has no {target} app on file yet.",
"detail": "Enter your own credentials under Settings → Integrations, then the connection can start."
},
"state": {
"pending_first_sync": {
"title": "{target} is connected.",
"detail": "The first sync is in progress. Readings appear on the dashboard as they arrive."
},
"fresh": {
"title": "{target} is already connected.",
"detail": "Last reading {when}. Nothing left to do here."
},
"stale": {
"title": "{target} is connected, but nothing is arriving right now.",
"detail": "Last reading {when}. The connection itself is fine."
},
"stalled": {
"title": "{target} is connected, but it has stopped syncing.",
"detail": "Last attempt {when}. Check the connection under Settings → Integrations."
},
"failing": {
"title": "{target} is connected, but the sync keeps failing.",
"detail": "Nothing has come through since {when}. Check the connection under Settings → Integrations."
},
"reauth_required": {
"title": "The connection to {target} needs renewing.",
"detail": "Sign in once more and the readings carry on."
},
"parked": {
"title": "The connection to {target} is paused.",
"detail": "Check the connection under Settings → Integrations."
}
}
},
"add-medication": {
"title": "Add your first medication",
+37 -3
View File
@@ -4677,12 +4677,46 @@
"connect-source": {
"title": "Conectar {target}",
"body": "Una conexión, elegida según tus respuestas. Después las mediciones llegan solas.",
"howTo": "La conexión se hace en la página de integraciones. Vuelve aquí después; esta pantalla detecta cuándo está lista.",
"howTo": "La conexión con {target} se hace en la página de integraciones. Vuelve aquí después; esta pantalla detecta cuándo está lista.",
"cta": "Conectar {target}",
"checking": "Comprobando la conexión.",
"waiting": "Aún no conectado.",
"done": "{target} está conectado.",
"syncing": "La primera sincronización está en marcha. Las mediciones aparecen en el panel a medida que llegan."
"openSettings": "Abrir Ajustes → Integraciones",
"noDate": "desconocido",
"needsCredentials": {
"title": "Esta instancia aún no tiene una aplicación de {target} registrada.",
"detail": "Introduce tus propias credenciales en Ajustes → Integraciones y la conexión podrá empezar."
},
"state": {
"pending_first_sync": {
"title": "{target} está conectado.",
"detail": "La primera sincronización está en marcha. Los valores aparecerán en el panel en cuanto lleguen."
},
"fresh": {
"title": "{target} ya está conectado.",
"detail": "Último valor {when}. Aquí no queda nada por hacer."
},
"stale": {
"title": "{target} está conectado, pero ahora mismo no llega nada.",
"detail": "Último valor {when}. La conexión en sí está bien."
},
"stalled": {
"title": "{target} está conectado, pero ha dejado de sincronizar.",
"detail": "Último intento {when}. Revisa la conexión en Ajustes → Integraciones."
},
"failing": {
"title": "{target} está conectado, pero la sincronización falla.",
"detail": "No ha llegado nada desde {when}. Revisa la conexión en Ajustes → Integraciones."
},
"reauth_required": {
"title": "Hay que renovar la conexión con {target}.",
"detail": "Inicia sesión una vez más y los valores seguirán llegando."
},
"parked": {
"title": "La conexión con {target} está en pausa.",
"detail": "Revisa la conexión en Ajustes → Integraciones."
}
}
},
"add-medication": {
"title": "Añade tu primer medicamento",
+37 -3
View File
@@ -4677,12 +4677,46 @@
"connect-source": {
"title": "Connecter {target}",
"body": "Une seule connexion, choisie d'après vos réponses. Les mesures arrivent ensuite d'elles-mêmes.",
"howTo": "La connexion se fait sur la page des intégrations. Revenez ici ensuite ; cet écran détecte quand elle est établie.",
"howTo": "La connexion à {target} se fait sur la page des intégrations. Revenez ici ensuite ; cet écran détecte quand elle est établie.",
"cta": "Connecter {target}",
"checking": "Vérification de la connexion.",
"waiting": "Pas encore connecté.",
"done": "{target} est connecté.",
"syncing": "La première synchronisation est en cours. Les mesures apparaissent sur le tableau de bord au fur et à mesure."
"openSettings": "Ouvrir Paramètres → Intégrations",
"noDate": "inconnue",
"needsCredentials": {
"title": "Cette instance n’a pas encore d’application {target} enregistrée.",
"detail": "Saisissez vos propres identifiants dans Paramètres → Intégrations, la connexion pourra alors démarrer."
},
"state": {
"pending_first_sync": {
"title": "{target} est connecté.",
"detail": "La première synchronisation est en cours. Les valeurs apparaîtront sur le tableau de bord dès leur arrivée."
},
"fresh": {
"title": "{target} est déjà connecté.",
"detail": "Dernière valeur {when}. Il n’y a plus rien à faire ici."
},
"stale": {
"title": "{target} est connecté, mais rien n’arrive en ce moment.",
"detail": "Dernière valeur {when}. La connexion elle-même est en ordre."
},
"stalled": {
"title": "{target} est connecté, mais la synchronisation ne tourne plus.",
"detail": "Dernière tentative {when}. Vérifiez la connexion dans Paramètres → Intégrations."
},
"failing": {
"title": "{target} est connecté, mais la synchronisation échoue.",
"detail": "Plus rien n’est arrivé depuis {when}. Vérifiez la connexion dans Paramètres → Intégrations."
},
"reauth_required": {
"title": "La connexion à {target} doit être renouvelée.",
"detail": "Reconnectez-vous une fois et les valeurs reprendront."
},
"parked": {
"title": "La connexion à {target} est en pause.",
"detail": "Vérifiez la connexion dans Paramètres → Intégrations."
}
}
},
"add-medication": {
"title": "Ajoutez votre premier médicament",
+37 -3
View File
@@ -4677,12 +4677,46 @@
"connect-source": {
"title": "Collega {target}",
"body": "Una sola connessione, scelta in base alle tue risposte. Poi le misurazioni arrivano da sole.",
"howTo": "La connessione si fa nella pagina delle integrazioni. Poi torna qui; questa schermata si accorge quando è pronta.",
"howTo": "La connessione a {target} si fa nella pagina delle integrazioni. Poi torna qui; questa schermata si accorge quando è pronta.",
"cta": "Collega {target}",
"checking": "Verifica della connessione in corso.",
"waiting": "Non ancora collegato.",
"done": "{target} è collegato.",
"syncing": "La prima sincronizzazione è in corso. Le misurazioni compaiono nella dashboard man mano che arrivano."
"openSettings": "Apri Impostazioni → Integrazioni",
"noDate": "sconosciuto",
"needsCredentials": {
"title": "Questa istanza non ha ancora un’app {target} registrata.",
"detail": "Inserisci le tue credenziali in Impostazioni → Integrazioni e la connessione potrà partire."
},
"state": {
"pending_first_sync": {
"title": "{target} è collegato.",
"detail": "La prima sincronizzazione è in corso. I valori compaiono sulla dashboard appena arrivano."
},
"fresh": {
"title": "{target} è già collegato.",
"detail": "Ultimo valore {when}. Qui non resta nulla da fare."
},
"stale": {
"title": "{target} è collegato, ma al momento non arriva nulla.",
"detail": "Ultimo valore {when}. La connessione in sé è a posto."
},
"stalled": {
"title": "{target} è collegato, ma non sincronizza più.",
"detail": "Ultimo tentativo {when}. Controlla la connessione in Impostazioni → Integrazioni."
},
"failing": {
"title": "{target} è collegato, ma la sincronizzazione non riesce.",
"detail": "Non arriva nulla da {when}. Controlla la connessione in Impostazioni → Integrazioni."
},
"reauth_required": {
"title": "La connessione a {target} va rinnovata.",
"detail": "Accedi ancora una volta e i valori riprendono ad arrivare."
},
"parked": {
"title": "La connessione a {target} è in pausa.",
"detail": "Controlla la connessione in Impostazioni → Integrazioni."
}
}
},
"add-medication": {
"title": "Aggiungi il tuo primo farmaco",
+37 -3
View File
@@ -4677,12 +4677,46 @@
"connect-source": {
"title": "{target} 연결",
"body": "답변을 바탕으로 고른 연결 하나입니다. 이후 측정값은 저절로 들어옵니다.",
"howTo": "연결은 연동 페이지에서 합니다. 마친 뒤 이곳으로 돌아오면 이 화면이 연결을 감지합니다.",
"howTo": "{target} 연결은 연동 페이지에서 합니다. 마친 뒤 이곳으로 돌아오면 이 화면이 연결을 감지합니다.",
"cta": "{target} 연결",
"checking": "연결을 확인하는 중입니다.",
"waiting": "아직 연결되지 않았습니다.",
"done": "{target}이(가) 연결되었습니다.",
"syncing": "첫 동기화가 진행 중입니다. 측정값이 도착하는 대로 대시보드에 나타납니다."
"openSettings": "설정 → 연동 열기",
"noDate": "알 수 없음",
"needsCredentials": {
"title": "이 인스턴스에는 아직 {target} 앱이 등록되어 있지 않습니다.",
"detail": "설정 → 연동에서 직접 발급받은 인증 정보를 입력하면 연결을 시작할 수 있습니다."
},
"state": {
"pending_first_sync": {
"title": "{target}이(가) 연결되었습니다.",
"detail": "첫 동기화가 진행 중입니다. 값이 도착하는 대로 대시보드에 표시됩니다."
},
"fresh": {
"title": "{target}은(는) 이미 연결되어 있습니다.",
"detail": "마지막 값 {when}. 여기서 더 할 일은 없습니다."
},
"stale": {
"title": "{target}은(는) 연결되어 있지만 지금은 값이 들어오지 않습니다.",
"detail": "마지막 값 {when}. 연결 자체에는 문제가 없습니다."
},
"stalled": {
"title": "{target}은(는) 연결되어 있지만 동기화가 멈췄습니다.",
"detail": "마지막 시도 {when}. 설정 → 연동에서 연결을 확인하세요."
},
"failing": {
"title": "{target}은(는) 연결되어 있지만 동기화가 계속 실패합니다.",
"detail": "{when} 이후로 아무것도 들어오지 않았습니다. 설정 → 연동에서 연결을 확인하세요."
},
"reauth_required": {
"title": "{target} 연결을 갱신해야 합니다.",
"detail": "한 번 다시 로그인하면 값이 계속 들어옵니다."
},
"parked": {
"title": "{target} 연결이 일시 중지되었습니다.",
"detail": "설정 → 연동에서 연결을 확인하세요."
}
}
},
"add-medication": {
"title": "첫 약 추가하기",
+37 -3
View File
@@ -4677,12 +4677,46 @@
"connect-source": {
"title": "Połącz {target}",
"body": "Jedno połączenie, wybrane na podstawie Twoich odpowiedzi. Potem pomiary przychodzą same.",
"howTo": "Połączenie nawiązuje się na stronie integracji. Wróć tutaj później; ten ekran zauważy, kiedy będzie gotowe.",
"howTo": "Połączenie z {target} nawiązuje się na stronie integracji. Wróć tutaj później; ten ekran zauważy, kiedy będzie gotowe.",
"cta": "Połącz {target}",
"checking": "Sprawdzanie połączenia.",
"waiting": "Jeszcze nie połączono.",
"done": "{target} jest połączone.",
"syncing": "Trwa pierwsza synchronizacja. Pomiary pojawią się na panelu, gdy tylko nadejdą."
"openSettings": "Otwórz Ustawienia → Integracje",
"noDate": "nieznany",
"needsCredentials": {
"title": "Ta instancja nie ma jeszcze zapisanej aplikacji {target}.",
"detail": "Wpisz własne dane dostępowe w Ustawieniach → Integracje, wtedy połączenie będzie mogło ruszyć."
},
"state": {
"pending_first_sync": {
"title": "{target} jest połączony.",
"detail": "Trwa pierwsza synchronizacja. Wartości pojawią się na pulpicie, gdy tylko dotrą."
},
"fresh": {
"title": "{target} jest już połączony.",
"detail": "Ostatnia wartość {when}. Tutaj nie ma już nic do zrobienia."
},
"stale": {
"title": "{target} jest połączony, ale w tej chwili nic nie przychodzi.",
"detail": "Ostatnia wartość {when}. Samo połączenie jest w porządku."
},
"stalled": {
"title": "{target} jest połączony, ale synchronizacja się zatrzymała.",
"detail": "Ostatnia próba {when}. Sprawdź połączenie w Ustawieniach → Integracje."
},
"failing": {
"title": "{target} jest połączony, ale synchronizacja się nie udaje.",
"detail": "Od {when} nic nie dotarło. Sprawdź połączenie w Ustawieniach → Integracje."
},
"reauth_required": {
"title": "Połączenie z {target} wymaga odnowienia.",
"detail": "Zaloguj się jeszcze raz, a wartości popłyną dalej."
},
"parked": {
"title": "Połączenie z {target} jest wstrzymane.",
"detail": "Sprawdź połączenie w Ustawieniach → Integracje."
}
}
},
"add-medication": {
"title": "Dodaj swój pierwszy lek",
@@ -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"
@@ -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;
}
}