mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
+18
-9
@@ -64323,9 +64323,12 @@ components:
|
||||
- error_reauth
|
||||
- disconnected
|
||||
- parked
|
||||
description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced
|
||||
sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an
|
||||
error.
|
||||
- unknown
|
||||
description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never
|
||||
synced sees: the ledger row is created on the first attempt, and its absence is no claim about the
|
||||
connection in either direction — it used to read as `connected`, which is why a client must never treat this
|
||||
field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a
|
||||
ledger state is painted."
|
||||
lastSuccessAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -65318,9 +65321,12 @@ components:
|
||||
- error_reauth
|
||||
- disconnected
|
||||
- parked
|
||||
description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced
|
||||
sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an
|
||||
error.
|
||||
- unknown
|
||||
description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never
|
||||
synced sees: the ledger row is created on the first attempt, and its absence is no claim about the
|
||||
connection in either direction — it used to read as `connected`, which is why a client must never treat this
|
||||
field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a
|
||||
ledger state is painted."
|
||||
lastSuccessAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -65724,9 +65730,12 @@ components:
|
||||
- error_reauth
|
||||
- disconnected
|
||||
- parked
|
||||
description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced
|
||||
sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an
|
||||
error.
|
||||
- unknown
|
||||
description: "Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never
|
||||
synced sees: the ledger row is created on the first attempt, and its absence is no claim about the
|
||||
connection in either direction — it used to read as `connected`, which is why a client must never treat this
|
||||
field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a
|
||||
ledger state is painted."
|
||||
lastSuccessAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
|
||||
@@ -118,7 +118,14 @@ const SCREENS: ScreenCase[] = [
|
||||
{
|
||||
screen: "first-result",
|
||||
path: "/onboarding/first-result",
|
||||
painted: (page) => page.locator('[data-slot="onboarding-task-connect"]'),
|
||||
// v1.38.19 (wave B) — wait for the tile the screen SETTLED on, not for
|
||||
// the connect card. This sweep used to name the connect card, which is
|
||||
// painted for the few hundred milliseconds before the status envelope
|
||||
// resolves whatever the account's real state is — so it scanned a frame
|
||||
// the person may never see, and passed straight through the defect that
|
||||
// frame was hiding. `data-connect-slot` appears only once the screen has
|
||||
// decided, and it is on every one of the four tiles.
|
||||
painted: (page) => page.locator("[data-connect-slot]"),
|
||||
prepare: (page) => complete(page),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* v1.38.19 (wave B / C1, C2) — the setup flow, path 5: a wearable on Q4.
|
||||
*
|
||||
* The connect arm is the only place in the flow that renders a claim about
|
||||
* real account state, and until this file no journey reached it: bp answers
|
||||
* `manual`, visit answers `file`, and the child path ends at confirm. The
|
||||
* a11y sweep does open the screen, but asserted the connect card was visible
|
||||
* — which it was, for the few hundred milliseconds before the status query
|
||||
* resolved. So the sweep passed THROUGH the defect it should have caught.
|
||||
*
|
||||
* Both cases here assert AFTER `GET /api/integrations/status` has settled,
|
||||
* which is the only moment at which the screen's claim means anything.
|
||||
*
|
||||
* Runs in one project (see `playwright.config.ts`): it resets and mutates its
|
||||
* own account.
|
||||
*/
|
||||
import pg from "pg";
|
||||
|
||||
import {
|
||||
E2E_SETUP_CONNECT,
|
||||
SETUP_CONNECT_STORAGE_STATE_PATH,
|
||||
} from "./setup/global-setup";
|
||||
import { resetSetupFlow } from "./setup/setup-flow-fixture";
|
||||
import type { Page, Response } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { answer, expectScreen, readMe } from "./setup-flow-helpers";
|
||||
|
||||
function pool(): pg.Pool {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("[setup-flow-connect] DATABASE_URL is not set");
|
||||
return new pg.Pool({ connectionString: url });
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the account's Oura back to "never connected". The journey's second case
|
||||
* writes a token column and a ledger row, neither of which `resetSetupFlow`
|
||||
* knows about — it resets the FLOW, and a leftover connection would make the
|
||||
* first case assert against the second case's state.
|
||||
*/
|
||||
async function clearOura(): Promise<void> {
|
||||
const db = pool();
|
||||
try {
|
||||
const { rows } = await db.query<{ id: string }>(
|
||||
`SELECT id FROM users WHERE username = $1`,
|
||||
[E2E_SETUP_CONNECT.username],
|
||||
);
|
||||
const id = rows[0]?.id;
|
||||
if (!id) throw new Error("[setup-flow-connect] no account");
|
||||
await db.query(
|
||||
`UPDATE users SET oura_access_token_encrypted = NULL WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
await db.query(
|
||||
`DELETE FROM integration_statuses WHERE user_id = $1 AND integration = 'oura'`,
|
||||
[id],
|
||||
);
|
||||
} finally {
|
||||
await db.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection that has been delivering for an hour: a stored token (the
|
||||
* envelope reads `connected` off the column's presence alone) and a ledger
|
||||
* row whose last success is recent enough for the `fresh` verdict.
|
||||
*/
|
||||
async function seedDeliveringOura(): Promise<void> {
|
||||
const db = pool();
|
||||
try {
|
||||
const { rows } = await db.query<{ id: string }>(
|
||||
`SELECT id FROM users WHERE username = $1`,
|
||||
[E2E_SETUP_CONNECT.username],
|
||||
);
|
||||
const id = rows[0]?.id;
|
||||
if (!id) throw new Error("[setup-flow-connect] no account");
|
||||
await db.query(
|
||||
`UPDATE users SET oura_access_token_encrypted = 'e2e-placeholder' WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
await db.query(
|
||||
`INSERT INTO integration_statuses
|
||||
(id, user_id, integration, state, last_success_at, last_attempt_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'oura', 'connected', NOW(), NOW(), NOW(), NOW())
|
||||
ON CONFLICT (user_id, integration) DO UPDATE
|
||||
SET state = 'connected',
|
||||
last_success_at = NOW(),
|
||||
last_attempt_at = NOW(),
|
||||
updated_at = NOW()`,
|
||||
[`e2e-connect-oura-${Date.now()}`, id],
|
||||
);
|
||||
} finally {
|
||||
await db.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the questions through the API, then open the screen under test.
|
||||
*
|
||||
* `areas` decides whether the priority has anywhere to fall to when the Q4
|
||||
* source turns out to be connected already: a Q2 area is a `log-reading` task,
|
||||
* an empty Q2 is nothing.
|
||||
*/
|
||||
async function reachFirstResult(
|
||||
page: Page,
|
||||
areas: readonly string[],
|
||||
): Promise<void> {
|
||||
await answer(page, { step: "who", recordTarget: "me" });
|
||||
await answer(page, {
|
||||
step: "areas",
|
||||
...(areas.length > 0 ? { areas } : { status: "skipped" }),
|
||||
});
|
||||
await answer(page, { step: "medication", medication: "no" });
|
||||
await answer(page, { step: "sources", sources: ["oura"] });
|
||||
await answer(page, { step: "visit", visit: "no" });
|
||||
const settled = page.waitForResponse(
|
||||
(res: Response) =>
|
||||
res.url().includes("/api/integrations/status") && res.status() === 200,
|
||||
);
|
||||
await page.goto("/onboarding/confirm");
|
||||
await expectScreen(page, "confirm");
|
||||
await page.locator('[data-slot="onboarding-next"]').click();
|
||||
await expectScreen(page, "first-result");
|
||||
// Every assertion below is about what the screen says once it KNOWS.
|
||||
await settled;
|
||||
}
|
||||
|
||||
// The three cases drive the one account, each setting up a different
|
||||
// connection state. Run in parallel they would each walk the others' state.
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("setup flow — a wearable on Q4", () => {
|
||||
test.use({ storageState: SETUP_CONNECT_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetSetupFlow(E2E_SETUP_CONNECT.username);
|
||||
await clearOura();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await clearOura();
|
||||
});
|
||||
|
||||
test("never claims a connection the account does not have", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/onboarding");
|
||||
await reachFirstResult(page, ["sleep"]);
|
||||
|
||||
// The tile the screen settled on: either the connect card, or — on an
|
||||
// instance with no Oura app configured — the credentials note. Never a
|
||||
// result: nothing was connected.
|
||||
const tile = page.locator("[data-connect-slot]");
|
||||
await expect(tile).toBeVisible({ timeout: 15_000 });
|
||||
const slot = await tile.getAttribute("data-connect-slot");
|
||||
expect(["connect", "credentials"]).toContain(slot);
|
||||
await expect(tile).toHaveAttribute("data-connect-state", "disconnected");
|
||||
await expect(
|
||||
page.locator('[data-slot="onboarding-first-result-done"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
// And the ledger was not stamped on the person's behalf.
|
||||
const me = await readMe(page);
|
||||
expect(me.onboarding.firstResult?.completedAt ?? null).toBeNull();
|
||||
});
|
||||
|
||||
test("offers the next task instead of a connection that already works", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await seedDeliveringOura();
|
||||
await page.goto("/onboarding");
|
||||
await reachFirstResult(page, ["sleep"]);
|
||||
|
||||
// The answers name one area as well, so the priority has somewhere to
|
||||
// fall to and the flow ends on something the person can actually do.
|
||||
await expect(page.locator("section[data-task]")).toHaveAttribute(
|
||||
"data-task",
|
||||
"log-reading",
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-slot="onboarding-task-connect"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("acknowledges a connection that is already delivering", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await seedDeliveringOura();
|
||||
await page.goto("/onboarding");
|
||||
// Q2 passed over, so the connection is the only task the answers name.
|
||||
await reachFirstResult(page, []);
|
||||
|
||||
const tile = page.locator('[data-connect-slot="result"]');
|
||||
await expect(tile).toBeVisible({ timeout: 15_000 });
|
||||
await expect(tile).toHaveAttribute("data-connect-state", "fresh");
|
||||
|
||||
// The acknowledgment replaces the offer: the connect CTA is gone.
|
||||
await expect(
|
||||
page.locator('[data-slot="onboarding-task-connect"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
// A working connection is a result the flow may record.
|
||||
await expect
|
||||
.poll(async () => (await readMe(page)).onboarding.firstResult?.task, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe("connect-source");
|
||||
});
|
||||
});
|
||||
@@ -480,6 +480,21 @@ export const E2E_SETUP_A11Y = {
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.38.19 (wave B) — the connect-source journey's account.
|
||||
*
|
||||
* The one arm of the flow that renders a claim about real account state had
|
||||
* no journey that reached it: bp answers `manual`, visit answers `file`, and
|
||||
* the child path ends at confirm. This account answers Q4 with a wearable, so
|
||||
* `/onboarding/first-result` really does render the connect step.
|
||||
*/
|
||||
export const E2E_SETUP_CONNECT = {
|
||||
email: "e2e-setup-connect@healthlog.test",
|
||||
username: "e2e-setup-connect",
|
||||
password: "Cn6!Zx4tB9wQm3Jf",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
export const E2E_SETUP_LOCALE = {
|
||||
email: "e2e-setup-locale@healthlog.test",
|
||||
username: "e2e-setup-locale",
|
||||
@@ -507,6 +522,10 @@ export const SETUP_A11Y_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateSetupA11y.json",
|
||||
);
|
||||
export const SETUP_CONNECT_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateSetupConnect.json",
|
||||
);
|
||||
export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateSetupLocale.json",
|
||||
@@ -519,6 +538,7 @@ const SETUP_FLOW_ACCOUNTS = [
|
||||
E2E_SETUP_CHILD,
|
||||
E2E_SETUP_A11Y,
|
||||
E2E_SETUP_LOCALE,
|
||||
E2E_SETUP_CONNECT,
|
||||
] as const;
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
@@ -1261,6 +1281,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
await capture(E2E_SETUP_VISIT, SETUP_VISIT_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SETUP_CONNECT, SETUP_CONNECT_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH);
|
||||
await pool.query(
|
||||
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
|
||||
|
||||
+37
-3
@@ -4683,12 +4683,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
@@ -4683,12 +4683,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
@@ -4683,12 +4683,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
@@ -4683,12 +4683,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
@@ -4683,12 +4683,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
@@ -4683,12 +4683,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
@@ -4683,12 +4683,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",
|
||||
|
||||
@@ -232,6 +232,7 @@ export default defineConfig({
|
||||
"setup-flow-visit.spec.ts",
|
||||
"setup-flow-child.spec.ts",
|
||||
"setup-flow-a11y.spec.ts",
|
||||
"setup-flow-connect.spec.ts",
|
||||
"setup-flow-locale-overflow.spec.ts",
|
||||
],
|
||||
use: {
|
||||
|
||||
@@ -60,6 +60,7 @@ const whoopFind = prisma.whoopConnection.findUnique as ReturnType<typeof vi.fn>;
|
||||
|
||||
type Entry = {
|
||||
integration: string;
|
||||
state?: string;
|
||||
connected?: boolean;
|
||||
configured?: boolean;
|
||||
available?: boolean;
|
||||
@@ -344,3 +345,41 @@ describe("/api/integrations/status — the freshness read failed", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 (wave B / I5) — the never-connected provider on the wire.
|
||||
*
|
||||
* The ledger's synthetic "no row" snapshot reads `unknown`, and the envelope
|
||||
* publishes it verbatim. A client that reads `state` must see a value that
|
||||
* cannot be mistaken for a live connection, while `syncHealth.verdict` keeps
|
||||
* saying `disconnected` for the same entry.
|
||||
*/
|
||||
describe("/api/integrations/status — a provider with no ledger row", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const key of Object.keys(perIntegrationLedger)) {
|
||||
delete perIntegrationLedger[key];
|
||||
}
|
||||
polarAvailable.mockResolvedValue(true);
|
||||
ouraAvailable.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("publishes state unknown beside connected false and a disconnected verdict", async () => {
|
||||
perIntegrationLedger.whoop = {
|
||||
state: "unknown",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
failingSince: null,
|
||||
};
|
||||
userFind.mockResolvedValue({});
|
||||
whoopFind.mockResolvedValue(null);
|
||||
|
||||
const whoop = (await fetchEntries()).find(
|
||||
(e) => e.integration === "whoop",
|
||||
)!;
|
||||
expect(whoop.state).toBe("unknown");
|
||||
expect(whoop.connected).toBe(false);
|
||||
expect(whoop.syncHealth?.verdict).toBe("disconnected");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("@/lib/crypto", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
getIntegrationStatus,
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
markReauthRequired,
|
||||
@@ -1368,3 +1369,25 @@ describe("recordSyncFailure — leg set", () => {
|
||||
expect(writtenUpdate().failingLegs).toEqual(["sleep"]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 (wave B / I5) — the synthetic "no row" snapshot.
|
||||
*
|
||||
* `state: "connected"` used to be the value an account with no sync history
|
||||
* read back, which made the ledger field's default mean the opposite of its
|
||||
* name: a provider nobody had ever connected published "connected" on the
|
||||
* envelope, and the setup flow's connect step believed it. The honest value
|
||||
* for "there is no history here" is `unknown`; `syncHealth.verdict` stays the
|
||||
* only liveness truth.
|
||||
*/
|
||||
describe("getIntegrationStatus with no ledger row", () => {
|
||||
it("reads as unknown, not as connected", async () => {
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce(
|
||||
null as never,
|
||||
);
|
||||
const snapshot = await getIntegrationStatus("u1", "whoop");
|
||||
expect(snapshot.state).toBe("unknown");
|
||||
expect(snapshot.lastSuccessAt).toBeNull();
|
||||
expect(snapshot.lastAttemptAt).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -159,7 +159,22 @@ export function isSyncFailureRecorded(err: unknown): boolean {
|
||||
* trail.
|
||||
*/
|
||||
export type IntegrationState =
|
||||
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
|
||||
| "connected"
|
||||
| "error_transient"
|
||||
| "error_reauth"
|
||||
| "disconnected"
|
||||
| "parked"
|
||||
/**
|
||||
* v1.38.19 — no ledger row exists for this (user, provider) pair, so the
|
||||
* ledger has nothing to say. It is NOT a claim about the connection either
|
||||
* way: `connected` used to be the synthetic default here, which made the
|
||||
* field's default read as the opposite of the truth for every account that
|
||||
* had never connected anything — the setup flow's connect step believed it
|
||||
* and told a brand-new account its wearable was connected. Consumers treat
|
||||
* `unknown` the way they treat `disconnected`; `syncHealth.verdict` stays
|
||||
* the only liveness truth.
|
||||
*/
|
||||
| "unknown";
|
||||
|
||||
/**
|
||||
* The ladder at which a streak of failures escalates from "user-visible
|
||||
@@ -323,9 +338,11 @@ function zeroBuckets(): ConsecutiveFailuresByKind {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current snapshot. Returns a synthetic "connected, never
|
||||
* attempted" record when no row exists yet — the UI treats this as
|
||||
* "no sync history" without a special case.
|
||||
* Read the current snapshot. Returns a synthetic "unknown, never attempted"
|
||||
* record when no row exists yet — the ledger holds no history, and says so
|
||||
* rather than guessing a state. `resolveSyncVerdict` reads the same absence
|
||||
* off the null timestamps and answers `pending_first_sync` for a live
|
||||
* connection, `disconnected` for one that is not there.
|
||||
*/
|
||||
export async function getIntegrationStatus(
|
||||
userId: string,
|
||||
@@ -337,7 +354,7 @@ export async function getIntegrationStatus(
|
||||
if (!row) {
|
||||
return {
|
||||
integration,
|
||||
state: "connected",
|
||||
state: "unknown",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
|
||||
@@ -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,8 @@ import { NAV_DESTINATIONS } from "@/components/layout/nav-model";
|
||||
import {
|
||||
AREA_PAGE_HREF,
|
||||
AREA_READING_TARGETS,
|
||||
connectSourceView,
|
||||
deliveringSources,
|
||||
SOURCE_INTEGRATION,
|
||||
} from "../first-result-config";
|
||||
import { BROWSER_CONNECTABLE_SOURCES } from "../wizard-steps";
|
||||
@@ -38,3 +43,216 @@ 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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 (wave B / I1) — which sources the flow must stop offering.
|
||||
*
|
||||
* Only a connection that is actually delivering, or one whose data has merely
|
||||
* gone quiet: both are real connections the person made, and offering to make
|
||||
* them again is the wizard talking past the account it can see. Everything
|
||||
* else — never connected, first sync running, needs repairing — stays in the
|
||||
* priority, because there is still something to do or something to say.
|
||||
*/
|
||||
describe("deliveringSources", () => {
|
||||
function entry(integration: string, verdict: string) {
|
||||
return {
|
||||
integration,
|
||||
connected: verdict !== "disconnected",
|
||||
configured: true,
|
||||
syncHealth: { verdict, since: null },
|
||||
lastSuccessAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
it("names the sources that are delivering or merely quiet", () => {
|
||||
const set = deliveringSources([
|
||||
entry("whoop", "fresh"),
|
||||
entry("oura", "stale"),
|
||||
entry("polar", "pending_first_sync"),
|
||||
entry("fitbit", "reauth_required"),
|
||||
entry("withings", "disconnected"),
|
||||
] as never);
|
||||
expect([...set].sort()).toEqual(["oura", "whoop"]);
|
||||
});
|
||||
|
||||
it("names nothing while the envelope has not resolved", () => {
|
||||
expect(deliveringSources(undefined).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -167,6 +167,46 @@ describe("chooseFirstResultTask", () => {
|
||||
).toEqual({ task: "log-reading", target: "weight-body" });
|
||||
expect(chooseFirstResultTask(emptyOnboardingNeeds())).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 (wave B / I1) — the choice used to read the answers alone, so an
|
||||
* account whose WHOOP had been delivering for months was told to go and
|
||||
* connect WHOOP. A source that is already delivering is not a task; the
|
||||
* priority falls through to the next one the person's answers name.
|
||||
*/
|
||||
it("passes over a source that is already delivering", () => {
|
||||
const needs: OnboardingNeeds = {
|
||||
...emptyOnboardingNeeds(),
|
||||
sources: ["whoop", "oura"],
|
||||
medication: "yes",
|
||||
areas: ["sleep"],
|
||||
};
|
||||
expect(chooseFirstResultTask(needs, new Set(["whoop"]))).toEqual({
|
||||
task: "connect-source",
|
||||
target: "oura",
|
||||
});
|
||||
expect(chooseFirstResultTask(needs, new Set(["whoop", "oura"]))).toEqual({
|
||||
task: "add-medication",
|
||||
target: null,
|
||||
});
|
||||
expect(
|
||||
chooseFirstResultTask(
|
||||
{ ...needs, medication: "no" },
|
||||
new Set(["whoop", "oura"]),
|
||||
),
|
||||
).toEqual({ task: "log-reading", target: "sleep" });
|
||||
});
|
||||
|
||||
it("is unchanged when nothing is known about the connections", () => {
|
||||
const needs: OnboardingNeeds = {
|
||||
...emptyOnboardingNeeds(),
|
||||
sources: ["whoop"],
|
||||
medication: "yes",
|
||||
};
|
||||
expect(chooseFirstResultTask(needs)).toEqual(
|
||||
chooseFirstResultTask(needs, new Set()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("firstResultApplies", () => {
|
||||
|
||||
@@ -7,9 +7,13 @@
|
||||
* 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";
|
||||
import {
|
||||
BROWSER_CONNECTABLE_SOURCES,
|
||||
type BrowserConnectableSource,
|
||||
} from "./wizard-steps";
|
||||
|
||||
export interface AreaReadingTarget {
|
||||
/** The `defaultType` the measurement form opens on. */
|
||||
@@ -47,19 +51,158 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sources the flow must stop offering to connect: the ones whose
|
||||
* connection is already delivering (`fresh`), and the ones whose data has
|
||||
* merely gone quiet (`stale`) — both are connections the person made, and
|
||||
* both would be "connect it" a second time.
|
||||
*
|
||||
* `pending_first_sync` is deliberately NOT in the set: that is a connection
|
||||
* made moments ago, and it is exactly the result this step exists to show.
|
||||
* Neither is anything on the attention arm — there is still something to say.
|
||||
*/
|
||||
export function deliveringSources(
|
||||
entries:
|
||||
ReadonlyArray<ConnectSourceStatus & { integration: string }> | undefined,
|
||||
): ReadonlySet<BrowserConnectableSource> {
|
||||
const delivering = new Set<BrowserConnectableSource>();
|
||||
if (!entries) return delivering;
|
||||
for (const source of BROWSER_CONNECTABLE_SOURCES) {
|
||||
const entry = entries.find(
|
||||
(candidate) =>
|
||||
candidate.integration === SOURCE_INTEGRATION[source].statusKey,
|
||||
);
|
||||
const verdict = connectSourceView(source, entry)?.verdict;
|
||||
if (verdict === "fresh" || verdict === "stale") delivering.add(source);
|
||||
}
|
||||
return delivering;
|
||||
}
|
||||
@@ -93,6 +93,8 @@ export function questionScreens(state: FlowState): OnboardingStepId[] {
|
||||
: [...FIXED_QUESTIONS];
|
||||
}
|
||||
|
||||
const EMPTY_CONNECTED: ReadonlySet<string> = new Set<string>();
|
||||
|
||||
export interface FirstResultOffer {
|
||||
task: OnboardingFirstResultTask;
|
||||
/** A source key for a connection, an area key for a reading, else null. */
|
||||
@@ -104,11 +106,21 @@ export interface FirstResultOffer {
|
||||
* the browser can complete → the first medication with a reminder, if the
|
||||
* answer to Q3 was a daily schedule → one reading for the first Q2 area.
|
||||
* Everything the priority passes over goes to the checklist instead.
|
||||
*
|
||||
* `connected` names the sources whose connection is already delivering. It is
|
||||
* optional because the server picks the order from the answers alone, before
|
||||
* any status is in hand; the screen re-picks once the envelope resolves. A
|
||||
* source in the set is not a task — an account whose WHOOP had been feeding
|
||||
* the dashboard for months was told to go and connect WHOOP, which is the
|
||||
* wizard talking past the account it can already see.
|
||||
*/
|
||||
export function chooseFirstResultTask(
|
||||
needs: OnboardingNeeds,
|
||||
connected: ReadonlySet<string> = EMPTY_CONNECTED,
|
||||
): FirstResultOffer | null {
|
||||
const connectable = needs.sources.find(isBrowserConnectableSource);
|
||||
const connectable = needs.sources.find(
|
||||
(source) => isBrowserConnectableSource(source) && !connected.has(source),
|
||||
);
|
||||
if (connectable) return { task: "connect-source", target: connectable };
|
||||
if (needs.medication === "yes")
|
||||
return { task: "add-medication", target: null };
|
||||
|
||||
@@ -326,9 +326,10 @@ const integrationLedgerState = z
|
||||
"error_reauth",
|
||||
"disconnected",
|
||||
"parked",
|
||||
"unknown",
|
||||
])
|
||||
.describe(
|
||||
"Last recorded ledger state for this provider. `connected` is also the value a user who has never synced sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an error.",
|
||||
"Last recorded ledger state for this provider. `unknown` (added v1.38.19) is what a user who has never synced sees: the ledger row is created on the first attempt, and its absence is no claim about the connection in either direction — it used to read as `connected`, which is why a client must never treat this field as liveness. Read `syncHealth.verdict` for that, and treat `unknown` as `disconnected` anywhere a ledger state is painted.",
|
||||
);
|
||||
|
||||
const nightscoutConnectRequest = nightscoutConnectSchema.meta({
|
||||
|
||||
@@ -14,5 +14,10 @@ export function resolveManagedIntegrationState(
|
||||
connected: Record<IntegrationKey, boolean>,
|
||||
integration: IntegrationKey,
|
||||
): IntegrationState {
|
||||
return connected[integration] ? state : "disconnected";
|
||||
if (!connected[integration]) return "disconnected";
|
||||
// v1.38.19 — `unknown` is the ledger saying it holds no row. The managed
|
||||
// projection publishes a closed vocabulary its card paints from, and
|
||||
// "no history" is not one of its states, so it reads as `disconnected`
|
||||
// here exactly as it does everywhere else.
|
||||
return state === "unknown" ? "disconnected" : state;
|
||||
}
|
||||
Reference in new issue
Block a user