mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge wave C — the flow cannot strand, mislead or silently switch off
The getting-started checklist's data queries follow the same setup rule as its visibility, so a settled account stops being told to do what it has done; a failed completion leaves the confirm screen usable; an empty multi-select is no longer recorded as an answer; the confirm screen names the modules a run would take out of the navigation; the settings links it offers work before the first completion; and demo mode can fill the baseline it is shown.
This commit is contained in:
commit
3642bbe4de
24 files changed
+1018
-122
No files matched your search
@@ -4666,6 +4666,7 @@
|
||||
"alwaysOn": "Immer an: {modules} und die Kernwerte (Gewicht, Blutdruck, Puls).",
|
||||
"everythingElse": "Alles andere bleibt einen Klick entfernt unter",
|
||||
"modulesLink": "Einstellungen, Module",
|
||||
"switchesOff": "Nicht mehr in der Navigation: {modules}. Jederzeit unter Einstellungen, Module wieder einschaltbar.",
|
||||
"glucoseUnit": "Blutzucker",
|
||||
"unitPreference": "Einheiten",
|
||||
"language": "Sprache",
|
||||
|
||||
@@ -4666,6 +4666,7 @@
|
||||
"alwaysOn": "Always on: {modules}, and the core readings (weight, blood pressure, pulse).",
|
||||
"everythingElse": "Everything else stays one click away under",
|
||||
"modulesLink": "Settings, Modules",
|
||||
"switchesOff": "Out of the navigation: {modules}. You can switch any of them back on under Settings, Modules.",
|
||||
"glucoseUnit": "Blood glucose",
|
||||
"unitPreference": "Units",
|
||||
"language": "Language",
|
||||
|
||||
@@ -4666,6 +4666,7 @@
|
||||
"alwaysOn": "Siempre activos: {modules} y las mediciones básicas (peso, presión arterial, pulso).",
|
||||
"everythingElse": "Todo lo demás queda a un clic en",
|
||||
"modulesLink": "Ajustes, Módulos",
|
||||
"switchesOff": "Fuera de la navegación: {modules}. Puedes volver a activarlos cuando quieras en Ajustes, Módulos.",
|
||||
"glucoseUnit": "Glucosa en sangre",
|
||||
"unitPreference": "Unidades",
|
||||
"language": "Idioma",
|
||||
|
||||
@@ -4666,6 +4666,7 @@
|
||||
"alwaysOn": "Toujours actifs : {modules}, et les mesures de base (poids, tension, pouls).",
|
||||
"everythingElse": "Tout le reste reste à un clic dans",
|
||||
"modulesLink": "Réglages, Modules",
|
||||
"switchesOff": "Hors de la navigation : {modules}. Tu peux les réactiver à tout moment dans Réglages, Modules.",
|
||||
"glucoseUnit": "Glycémie",
|
||||
"unitPreference": "Unités",
|
||||
"language": "Langue",
|
||||
|
||||
@@ -4666,6 +4666,7 @@
|
||||
"alwaysOn": "Sempre attivi: {modules} e le misurazioni di base (peso, pressione, polso).",
|
||||
"everythingElse": "Tutto il resto resta a un clic in",
|
||||
"modulesLink": "Impostazioni, Moduli",
|
||||
"switchesOff": "Fuori dalla navigazione: {modules}. Puoi riattivarli quando vuoi in Impostazioni, Moduli.",
|
||||
"glucoseUnit": "Glicemia",
|
||||
"unitPreference": "Unità",
|
||||
"language": "Lingua",
|
||||
|
||||
@@ -4666,6 +4666,7 @@
|
||||
"alwaysOn": "항상 켜짐: {modules}, 그리고 기본 측정값(체중, 혈압, 맥박).",
|
||||
"everythingElse": "나머지는 한 번의 클릭으로 열 수 있습니다:",
|
||||
"modulesLink": "설정, 모듈",
|
||||
"switchesOff": "탐색에서 빠집니다: {modules}. 설정, 모듈에서 언제든 다시 켤 수 있습니다.",
|
||||
"glucoseUnit": "혈당",
|
||||
"unitPreference": "단위",
|
||||
"language": "언어",
|
||||
|
||||
@@ -4666,6 +4666,7 @@
|
||||
"alwaysOn": "Zawsze włączone: {modules} oraz podstawowe pomiary (waga, ciśnienie, tętno).",
|
||||
"everythingElse": "Cała reszta jest o jedno kliknięcie w",
|
||||
"modulesLink": "Ustawienia, Moduły",
|
||||
"switchesOff": "Znika z nawigacji: {modules}. W każdej chwili możesz je włączyć w Ustawienia, Moduły.",
|
||||
"glucoseUnit": "Glukoza we krwi",
|
||||
"unitPreference": "Jednostki",
|
||||
"language": "Język",
|
||||
|
||||
@@ -31,12 +31,19 @@ const SRC = join(process.cwd(), "src");
|
||||
const PROFILE_ROUTES = ["/api/auth/profile", "/api/user/profile"];
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.startsWith("app/api/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
return (
|
||||
walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.startsWith("app/api/"))
|
||||
// v1.39 (Wave C, C6) — the edge proxy names routes and methods as DATA
|
||||
// (the demo-mode mutation allowlist is a list of path/method pairs), and
|
||||
// a pair reads to the matcher below exactly like a request expression. It
|
||||
// submits nothing and has no answer to read.
|
||||
.filter((p) => p !== "proxy.ts")
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort()
|
||||
);
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
|
||||
@@ -106,6 +106,36 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
|
||||
expect(missing, "onboarding writes the demo cannot make").toEqual([]);
|
||||
});
|
||||
|
||||
it("permits the baseline writes the confirm screen makes (M-m)", () => {
|
||||
// v1.39 (Wave C, C6) — a demo visitor who typed a height got a 403 and a
|
||||
// generic toast, and "someone I look after" could only take "Finish
|
||||
// without the profile": the three writes the confirm screen makes were
|
||||
// not on the list. None of them is health data — the account's own
|
||||
// profile fields, the encrypted self-context the anamnesis card writes,
|
||||
// and the managed record the flow's guardian arm creates.
|
||||
for (const [path, method] of [
|
||||
["/api/auth/profile", "PUT"],
|
||||
["/api/coach/about-me", "PUT"],
|
||||
["/api/managed-profiles", "POST"],
|
||||
] as const) {
|
||||
expect(
|
||||
proxy(makeRequest(path, method)).status,
|
||||
`${method} ${path} is blocked in the demo`,
|
||||
).not.toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not drag in a sibling verb on the baseline paths", () => {
|
||||
expect(proxy(makeRequest("/api/auth/profile", "DELETE")).status).toBe(403);
|
||||
expect(proxy(makeRequest("/api/managed-profiles", "DELETE")).status).toBe(
|
||||
403,
|
||||
);
|
||||
// The per-profile routes are a different path and stay closed.
|
||||
expect(
|
||||
proxy(makeRequest("/api/managed-profiles/abc", "DELETE")).status,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it("still blocks a health-data mutation (POST /api/measurements)", () => {
|
||||
const res = proxy(makeRequest("/api/measurements", "POST"));
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* v1.39 (Wave C, C2) — a failed completion leaves the confirm screen usable.
|
||||
*
|
||||
* Two defects, one symptom. `advance()` in the baseline form set its pending
|
||||
* flag and never reset it on the success path, and `finish()` on the confirm
|
||||
* screen caught its own error and returned normally — so a failed
|
||||
* `POST /api/onboarding/complete` (or a 429 on the answer PATCHes before it)
|
||||
* left "Überspringen" and "Bestätigen und weiter" disabled for the rest of the
|
||||
* page's life and only a reload recovered (research I10).
|
||||
*
|
||||
* The second defect is the retry itself: `finish()` fires one PATCH per
|
||||
* question the flow never showed, and a failure midway left a partly-passed
|
||||
* ledger that the next attempt walked from the top again (M-j).
|
||||
*
|
||||
* Both are pinned at the seam the component drives, because this repo's
|
||||
* component tests render server-side and cannot click.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
defaultOnboardingSteps,
|
||||
emptyOnboardingNeeds,
|
||||
type OnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs";
|
||||
|
||||
import { runBaselineAttempt } from "../baseline-form";
|
||||
import { runConfirmFinish } from "../confirm-screen";
|
||||
|
||||
type PassableStep = Parameters<
|
||||
Parameters<typeof runConfirmFinish>[0]["passStep"]
|
||||
>[0];
|
||||
|
||||
/**
|
||||
* A flow with no unit-bearing area: Q6 is the question the machine never
|
||||
* showed, so the confirm screen has to pass it on the ledger before the
|
||||
* completion.
|
||||
*/
|
||||
function state(
|
||||
overrides: Partial<OnboardingStateDto> = {},
|
||||
): OnboardingStateDto {
|
||||
return {
|
||||
steps: defaultOnboardingSteps().map((step) =>
|
||||
step.id === "who" ? { ...step, status: "done" as const } : step,
|
||||
),
|
||||
needs: { ...emptyOnboardingNeeds(), recordTarget: "me" },
|
||||
completedAt: null,
|
||||
firstResult: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** The ledger as the server would answer it after one step was passed. */
|
||||
function withPassed(
|
||||
base: OnboardingStateDto,
|
||||
ids: readonly string[],
|
||||
): OnboardingStateDto {
|
||||
return {
|
||||
...base,
|
||||
steps: base.steps.map((step) =>
|
||||
ids.includes(step.id) ? { ...step, status: "skipped" as const } : step,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runBaselineAttempt", () => {
|
||||
it("hands the buttons back when the completion rejects", async () => {
|
||||
const pending: boolean[] = [];
|
||||
const onError = vi.fn();
|
||||
await runBaselineAttempt(
|
||||
(value) => pending.push(value),
|
||||
async () => {
|
||||
throw new Error("complete failed");
|
||||
},
|
||||
onError,
|
||||
);
|
||||
expect(pending).toEqual([true, false]);
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hands the buttons back on the success path too", async () => {
|
||||
const pending: boolean[] = [];
|
||||
await runBaselineAttempt(
|
||||
(value) => pending.push(value),
|
||||
async () => {},
|
||||
vi.fn(),
|
||||
);
|
||||
expect(pending).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("is the form's only way to move the pending flag", () => {
|
||||
// A second, inline `setSaving(false)` is how the flag stopped being
|
||||
// reset on one of the three exits in the first place.
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/components/onboarding/baseline-form.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).not.toContain("setSaving(false)");
|
||||
expect(source).not.toContain("setSaving(true)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runConfirmFinish", () => {
|
||||
it("rethrows a failed completion instead of returning normally", async () => {
|
||||
const ledger = { current: state() };
|
||||
const passed: PassableStep[] = [];
|
||||
const navigate = vi.fn();
|
||||
await expect(
|
||||
runConfirmFinish({
|
||||
ledger,
|
||||
// The server answers the written ledger; only the runner may put it
|
||||
// back, which is the whole point of the ref.
|
||||
passStep: async (step) => {
|
||||
passed.push(step);
|
||||
return withPassed(ledger.current, [step]);
|
||||
},
|
||||
complete: async () => {
|
||||
throw new Error("429");
|
||||
},
|
||||
navigate,
|
||||
}),
|
||||
).rejects.toThrow("429");
|
||||
// Q6 was never shown for a flow with no unit-bearing area, so it is
|
||||
// passed on the ledger before the completion — and that write survived.
|
||||
expect(passed).toEqual(["units"]);
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not pass a step the failed attempt already passed (M-j)", async () => {
|
||||
const ledger = { current: state() };
|
||||
const passed: PassableStep[] = [];
|
||||
const passStep = async (step: PassableStep) => {
|
||||
passed.push(step);
|
||||
return withPassed(ledger.current, [step]);
|
||||
};
|
||||
let attempt = 0;
|
||||
const complete = async () => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) throw new Error("429");
|
||||
return { onboarding: ledger.current };
|
||||
};
|
||||
const navigate = vi.fn();
|
||||
|
||||
await expect(
|
||||
runConfirmFinish({ ledger, passStep, complete, navigate }),
|
||||
).rejects.toThrow("429");
|
||||
await runConfirmFinish({ ledger, passStep, complete, navigate });
|
||||
|
||||
// "units" exactly once across both attempts; "first-result" is the
|
||||
// second attempt's own pass, because these answers offer no task.
|
||||
expect(passed).toEqual(["units", "first-result"]);
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* v1.39 (Wave C) — what the confirm screen is allowed to claim.
|
||||
*
|
||||
* C5 (research M-l): both of the screen's own Settings links are page routes,
|
||||
* and `hl_onboarding=pending` is still set while this screen is on — the
|
||||
* completion is what clears it, and this screen is what calls the completion.
|
||||
* The proxy sends both straight back to the flow, so on a first run they are
|
||||
* dead links under a sentence that is not yet true.
|
||||
*
|
||||
* C4 (research I6): the screen listed what the answers switch ON and said
|
||||
* everything else "stays one click away under Settings, Modules" — true of
|
||||
* reachability, false of the navigation entry. A module the answers do not
|
||||
* ask for leaves the navigation, and nothing said so.
|
||||
*
|
||||
* Rendered server-side, this repo's component-test convention.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
import {
|
||||
defaultOnboardingSteps,
|
||||
emptyOnboardingNeeds,
|
||||
type OnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs";
|
||||
|
||||
let currentUser: AuthUser | null = null;
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: currentUser }),
|
||||
useAccountOnceMounted: () => currentUser,
|
||||
}));
|
||||
|
||||
const { ConfirmScreen } = await import("../confirm-screen");
|
||||
|
||||
function state(
|
||||
overrides: Partial<OnboardingStateDto> = {},
|
||||
needs: Partial<OnboardingStateDto["needs"]> = {},
|
||||
): OnboardingStateDto {
|
||||
return {
|
||||
steps: defaultOnboardingSteps(),
|
||||
needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs },
|
||||
completedAt: null,
|
||||
firstResult: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(
|
||||
flow: OnboardingStateDto,
|
||||
modules: Partial<Record<string, boolean>> = {},
|
||||
): string {
|
||||
currentUser = {
|
||||
id: "u1",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
unitPreference: "metric",
|
||||
modules,
|
||||
} as unknown as AuthUser;
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<ConfirmScreen state={flow} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ConfirmScreen> — modules leaving the navigation (C4)", () => {
|
||||
it("names them when the answers do not ask for what the record shows", () => {
|
||||
const html = render(state({}, { areas: ["sleep"], medication: "no" }), {
|
||||
medications: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="onboarding-confirm-modules-off"');
|
||||
expect(html).toContain("Medications");
|
||||
});
|
||||
|
||||
it("says nothing when the answers take nothing away", () => {
|
||||
const html = render(state({}, { areas: ["sleep"], medication: "yes" }), {
|
||||
medications: true,
|
||||
});
|
||||
expect(html).not.toContain('data-slot="onboarding-confirm-modules-off"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ConfirmScreen> — Settings links on a first run (C5)", () => {
|
||||
it("renders both as plain text while the completion has not run", () => {
|
||||
const html = render(state());
|
||||
expect(html).not.toContain('href="/settings/modules"');
|
||||
expect(html).not.toContain('href="/settings/account"');
|
||||
// The words stay: they say where the thing lives, which is true.
|
||||
expect(html).toContain("Settings, Modules");
|
||||
expect(html).toContain("Settings, Account");
|
||||
});
|
||||
|
||||
it("renders them as links once the flow has been completed once", () => {
|
||||
const html = render(state({ completedAt: "2026-09-10T08:00:00.000Z" }), {});
|
||||
expect(html).toContain('href="/settings/modules"');
|
||||
expect(html).toContain('href="/settings/account"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* v1.39 (Wave C, C1) — the checklist's data queries follow the same rule as
|
||||
* its visibility.
|
||||
*
|
||||
* `shouldShowChecklist` gained `hasEnteredOnboardingFlow` as a third
|
||||
* disjunct; `checklistRelevant` in the component kept restating the older
|
||||
* half of the rule, so a wizard-run account past five readings still saw the
|
||||
* card while every supporting query was `enabled: false` — the medication,
|
||||
* data-source, notification and insights rows read "not done" for things the
|
||||
* account had already done (research I9).
|
||||
*
|
||||
* Rendered SSR-only, per this repo's component-test convention. `useQuery` is
|
||||
* mocked to honour `enabled` exactly the way tanstack does — a disabled query
|
||||
* has no data — so what the rows say here is what the dashboard says.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
import {
|
||||
defaultOnboardingSteps,
|
||||
emptyOnboardingNeeds,
|
||||
type OnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs";
|
||||
|
||||
let currentUser: AuthUser | null = null;
|
||||
/** Every `enabled` flag the component asked for, keyed by its query key. */
|
||||
const enabledByKey = new Map<string, boolean>();
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAccountOnceMounted: () => currentUser,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/queries/use-dashboard-snapshot", () => ({
|
||||
useDashboardSnapshot: () => ({
|
||||
data: { tiles: { summaries: { weight: { count: 12 } } } },
|
||||
}),
|
||||
}));
|
||||
|
||||
const RESPONSES: Record<string, unknown> = {
|
||||
'["medications"]': [{ id: "m1" }, { id: "m2" }, { id: "m3" }],
|
||||
'["user","ai-provider"]': { aiAvailable: true },
|
||||
'["integrations","status"]': {
|
||||
integrations: [{ integration: "whoop", connected: true }],
|
||||
},
|
||||
// Deliberately NOT configured: one open row keeps the card on screen, so
|
||||
// the rows this test is about are actually rendered.
|
||||
'["notifications","preferences"]': { channels: [{ enabled: false }] },
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
|
||||
return {
|
||||
...actual,
|
||||
useQuery: (options: {
|
||||
queryKey: readonly unknown[];
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
const key = JSON.stringify(options.queryKey);
|
||||
const enabled = options.enabled !== false;
|
||||
enabledByKey.set(key, enabled);
|
||||
return { data: enabled ? RESPONSES[key] : undefined };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { GettingStartedChecklist } =
|
||||
await import("../getting-started-checklist");
|
||||
|
||||
/** A record the flow really finished: Q1 answered, confirmed, task settled. */
|
||||
function settledOnboarding(): OnboardingStateDto {
|
||||
return {
|
||||
steps: defaultOnboardingSteps().map((step) => ({
|
||||
...step,
|
||||
status: "done" as const,
|
||||
})),
|
||||
needs: { ...emptyOnboardingNeeds(), recordTarget: "me" },
|
||||
completedAt: "2026-09-10T08:00:00.000Z",
|
||||
firstResult: {
|
||||
task: "log-reading",
|
||||
target: null,
|
||||
completedAt: "2026-09-10T08:05:00.000Z",
|
||||
},
|
||||
} as OnboardingStateDto;
|
||||
}
|
||||
|
||||
function user(overrides: Partial<AuthUser> = {}): AuthUser {
|
||||
return {
|
||||
id: "u1",
|
||||
email: "a@b.c",
|
||||
username: "a",
|
||||
heightCm: 175,
|
||||
dateOfBirth: "1990-01-01T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
onboardingCompletedAt: "2026-09-10T08:00:00.000Z",
|
||||
onboarding: settledOnboarding(),
|
||||
...overrides,
|
||||
} as AuthUser;
|
||||
}
|
||||
|
||||
function render(): string {
|
||||
enabledByKey.clear();
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<GettingStartedChecklist />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<GettingStartedChecklist> — rows for an established account", () => {
|
||||
it("reads the real medication, data-source and insights state once the flow is behind the account", () => {
|
||||
currentUser = user();
|
||||
const html = render();
|
||||
|
||||
// The card is on screen: the notifications row is genuinely open. The
|
||||
// list itself is collapsed on a server paint, so the progress meter is
|
||||
// what the rows add up to — profile, measurement, medication, data
|
||||
// source and insights done, notifications not.
|
||||
expect(html).toContain('data-testid="onboarding-card"');
|
||||
expect(html).toContain("5 of 6 done");
|
||||
});
|
||||
|
||||
it("keeps the supporting queries enabled while the card can render", () => {
|
||||
currentUser = user();
|
||||
render();
|
||||
expect(enabledByKey.get('["medications"]')).toBe(true);
|
||||
expect(enabledByKey.get('["integrations","status"]')).toBe(true);
|
||||
expect(enabledByKey.get('["notifications","preferences"]')).toBe(true);
|
||||
expect(enabledByKey.get('["user","ai-provider"]')).toBe(true);
|
||||
});
|
||||
|
||||
it("still fetches nothing for a record that never entered the flow and is long past five readings", () => {
|
||||
currentUser = user({ onboarding: null });
|
||||
render();
|
||||
expect(enabledByKey.get('["medications"]')).toBe(false);
|
||||
expect(enabledByKey.get('["integrations","status"]')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* v1.39 (Wave C, C3) — Q6 cannot be answered with nothing chosen (M-k).
|
||||
*
|
||||
* "Weiter" used to be live on an untouched units screen: it wrote
|
||||
* `{ units: {} }`, which marked the step DONE without recording a preference,
|
||||
* so the account carried "asked and answered" for a question nobody answered.
|
||||
* Skip is the way past a question with no opinion, and Skip says so on the
|
||||
* ledger.
|
||||
*
|
||||
* Rendered server-side, this repo's component-test convention.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
defaultOnboardingSteps,
|
||||
emptyOnboardingNeeds,
|
||||
type OnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs";
|
||||
|
||||
import { UnitsScreen } from "../units-screen";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: null }),
|
||||
}));
|
||||
|
||||
function state(
|
||||
needs: Partial<OnboardingStateDto["needs"]>,
|
||||
): OnboardingStateDto {
|
||||
return {
|
||||
steps: defaultOnboardingSteps(),
|
||||
needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs },
|
||||
completedAt: null,
|
||||
firstResult: null,
|
||||
};
|
||||
}
|
||||
|
||||
function render(needs: Partial<OnboardingStateDto["needs"]>): string {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<UnitsScreen state={state(needs)} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the primary button's own tag disabled? Read off the tag rather than the
|
||||
* whole page, and off the attribute rather than the string "disabled", which
|
||||
* also occurs in the button's `disabled:` utility classes.
|
||||
*/
|
||||
function nextIsDisabled(html: string): boolean {
|
||||
const at = html.indexOf('data-slot="onboarding-next"');
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
const open = html.lastIndexOf("<button", at);
|
||||
return html.slice(open, html.indexOf(">", at) + 1).includes('disabled=""');
|
||||
}
|
||||
|
||||
describe("<UnitsScreen>", () => {
|
||||
it("does not let the person continue without choosing a unit", () => {
|
||||
const html = render({ areas: ["glucose"] });
|
||||
expect(nextIsDisabled(html)).toBe(true);
|
||||
// Skip is still there: it is the honest way past a question with no
|
||||
// opinion, and it says "skipped" on the ledger.
|
||||
expect(html).toContain('data-slot="onboarding-skip"');
|
||||
});
|
||||
|
||||
it("lets the person continue once a unit is chosen", () => {
|
||||
const html = render({
|
||||
areas: ["glucose"],
|
||||
units: { glucoseUnit: "mg/dL", unitPreference: null },
|
||||
});
|
||||
expect(nextIsDisabled(html)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,37 @@ interface BaselineFormState {
|
||||
gender: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39 (Wave C, C2) — one attempt at confirming, with the buttons always
|
||||
* handed back.
|
||||
*
|
||||
* `advance()` used to raise its own pending flag and lower it again on two of
|
||||
* the three exits: the refused-field return and its catch. The third exit —
|
||||
* the success path, `await onConfirmed()` — never lowered it, and
|
||||
* `onConfirmed` is the confirm screen's completion, which caught its own
|
||||
* error and returned normally. So a failed completion disabled "Skip" and
|
||||
* "Confirm and continue" for the rest of the page's life and only a reload
|
||||
* recovered (research I10).
|
||||
*
|
||||
* The reset lives in a `finally` here, which is the one place it cannot be
|
||||
* forgotten on a new exit, and the component has no other way to move the
|
||||
* flag.
|
||||
*/
|
||||
export async function runBaselineAttempt(
|
||||
setPending: (pending: boolean) => void,
|
||||
attempt: () => Promise<void>,
|
||||
onError: (err: unknown) => void,
|
||||
): Promise<void> {
|
||||
setPending(true);
|
||||
try {
|
||||
await attempt();
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function BaselineForm({
|
||||
initial,
|
||||
backHref,
|
||||
@@ -152,50 +183,50 @@ export function BaselineForm({
|
||||
|
||||
async function advance(opts: { saveProfile: boolean }) {
|
||||
if (saving || confirming) return;
|
||||
setSaving(true);
|
||||
setFieldErrors({});
|
||||
try {
|
||||
if (opts.saveProfile) {
|
||||
const profileBody = buildBaselineProfileBody(
|
||||
form,
|
||||
heightAdapter.toCanonicalCm(form.height),
|
||||
);
|
||||
if (Object.keys(profileBody).length > 0) {
|
||||
const outcome = describeBaselineSaveOutcome(
|
||||
await putBaselineProfile(profileBody),
|
||||
t,
|
||||
baselineFieldLabelKeys(heightAdapter.usesFeetInches),
|
||||
await runBaselineAttempt(
|
||||
setSaving,
|
||||
async () => {
|
||||
if (opts.saveProfile) {
|
||||
const profileBody = buildBaselineProfileBody(
|
||||
form,
|
||||
heightAdapter.toCanonicalCm(form.height),
|
||||
);
|
||||
if (outcome.notice) {
|
||||
const show =
|
||||
outcome.notice.tone === "warning" ? toast.warning : toast.error;
|
||||
show(outcome.notice.message);
|
||||
if (Object.keys(profileBody).length > 0) {
|
||||
const outcome = describeBaselineSaveOutcome(
|
||||
await putBaselineProfile(profileBody),
|
||||
t,
|
||||
baselineFieldLabelKeys(heightAdapter.usesFeetInches),
|
||||
);
|
||||
if (outcome.notice) {
|
||||
const show =
|
||||
outcome.notice.tone === "warning" ? toast.warning : toast.error;
|
||||
show(outcome.notice.message);
|
||||
}
|
||||
if (!outcome.advance) {
|
||||
// A field was refused. Staying on the step is the point —
|
||||
// each refused input now says why under itself, the values
|
||||
// the person typed are still in front of them, and the
|
||||
// account is not stamped as set up over a value that never
|
||||
// landed.
|
||||
setFieldErrors(outcome.fieldErrors);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!outcome.advance) {
|
||||
// A field was refused. Staying on the step is the point —
|
||||
// each refused input now says why under itself, the values
|
||||
// the person typed are still in front of them, and the
|
||||
// account is not stamped as set up over a value that never
|
||||
// landed.
|
||||
setFieldErrors(outcome.fieldErrors);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Anamnesis — only write when the user actually filled a field
|
||||
// (the helper returns null for an untouched card, so a
|
||||
// collapsed card never round-trips).
|
||||
const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis);
|
||||
if (aboutMeBody) {
|
||||
await apiPut("/api/coach/about-me", aboutMeBody);
|
||||
// Anamnesis — only write when the user actually filled a field
|
||||
// (the helper returns null for an untouched card, so a
|
||||
// collapsed card never round-trips).
|
||||
const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis);
|
||||
if (aboutMeBody) {
|
||||
await apiPut("/api/coach/about-me", aboutMeBody);
|
||||
}
|
||||
}
|
||||
}
|
||||
await onConfirmed();
|
||||
} catch (err) {
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
|
||||
setSaving(false);
|
||||
}
|
||||
await onConfirmed();
|
||||
},
|
||||
(err) =>
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric")),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
@@ -20,7 +20,14 @@ import { localeLabels } from "@/lib/i18n/config";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { MODULE_REGISTRY } from "@/lib/modules/registry";
|
||||
import { confirmedModules } from "@/lib/onboarding/confirm-summary";
|
||||
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
|
||||
import {
|
||||
ONBOARDING_SKIPPABLE_STEP_IDS,
|
||||
type OnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs";
|
||||
|
||||
/** A step the confirm screen may pass on the ledger without an answer. */
|
||||
type PassableStepId = (typeof ONBOARDING_SKIPPABLE_STEP_IDS)[number];
|
||||
import { isOnboardingSettled } from "@/lib/onboarding/needs";
|
||||
import {
|
||||
firstResultApplies,
|
||||
nextScreen,
|
||||
@@ -51,6 +58,40 @@ import {
|
||||
* offer, otherwise done. When there is no task, the first-result step is
|
||||
* passed explicitly so the ledger reads as settled rather than owed.
|
||||
*/
|
||||
/**
|
||||
* v1.39 (Wave C, C2) — the completion sequence, apart from the component so
|
||||
* a failure partway through it can be pinned without a browser.
|
||||
*
|
||||
* Passes every question the flow never showed (the route derives only once
|
||||
* EVERY question is answered or passed, and a step nobody was asked cannot be
|
||||
* answered), completes, passes the first-result step when there is no task to
|
||||
* offer, and navigates. It reports a failure by THROWING — the caller owns the
|
||||
* buttons and the toast.
|
||||
*/
|
||||
export async function runConfirmFinish(args: {
|
||||
/** The ledger as the last write left it; updated in place as steps pass. */
|
||||
ledger: { current: OnboardingStateDto };
|
||||
managedRecordId?: string;
|
||||
passStep: (step: PassableStepId) => Promise<OnboardingStateDto>;
|
||||
complete: (
|
||||
managedRecordId?: string,
|
||||
) => Promise<{ onboarding?: OnboardingStateDto | null }>;
|
||||
navigate: (href: string) => void;
|
||||
}): Promise<void> {
|
||||
for (const id of questionsToPassBeforeConfirm(args.ledger.current)) {
|
||||
args.ledger.current = await args.passStep(id);
|
||||
}
|
||||
const { onboarding } = await args.complete(args.managedRecordId);
|
||||
let written = onboarding ?? args.ledger.current;
|
||||
if (!firstResultApplies(written)) {
|
||||
// Nothing to offer, so the step is passed rather than left owed: a
|
||||
// pending step would keep the setup reading as unfinished forever.
|
||||
written = await args.passStep("first-result");
|
||||
}
|
||||
args.ledger.current = written;
|
||||
args.navigate(screenHref(nextScreen(written, "confirm") ?? "done"));
|
||||
}
|
||||
|
||||
export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
const { t, locale } = useTranslations();
|
||||
const router = useRouter();
|
||||
@@ -58,10 +99,37 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
const complete = useOnboardingComplete();
|
||||
const answer = useOnboardingAnswer();
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
/**
|
||||
* v1.39 (Wave C, C2) — the ledger as the last write left it (M-j).
|
||||
* `finish()` sends one PATCH per question the flow never showed; a failure
|
||||
* midway used to leave a partly-passed ledger that the next attempt walked
|
||||
* from the top again, because the `state` prop it read was the one this
|
||||
* screen was rendered with. Every mutation answers the written state, so
|
||||
* the ref carries the progress across attempts and
|
||||
* `questionsToPassBeforeConfirm` — which lists pending steps only — then
|
||||
* skips what the failed attempt already passed.
|
||||
*/
|
||||
const ledger = useRef(state);
|
||||
|
||||
const needs = state.needs;
|
||||
/**
|
||||
* v1.39 (Wave C, C5) — is `hl_onboarding=pending` still set? (research M-l)
|
||||
*
|
||||
* Both of this screen's Settings links are page routes, and the cookie is
|
||||
* cleared by the completion — which is what THIS screen calls. So on a
|
||||
* first run the proxy 307s both straight back into the flow: two links
|
||||
* that cannot go anywhere, under a sentence saying where things live. The
|
||||
* words are true, the links are not, so only the links go.
|
||||
*/
|
||||
const settingsReachable =
|
||||
isOnboardingSettled(state) || state.completedAt !== null;
|
||||
const forSomeoneElse = needs.recordTarget === "someone-else";
|
||||
const { chosen, alwaysOn } = confirmedModules(needs);
|
||||
// v1.39 (Wave C, C4) — `user.modules` is the map the navigation reads, so
|
||||
// the screen can only name a module the person actually has today.
|
||||
const { chosen, alwaysOn, wouldSwitchOff } = confirmedModules(
|
||||
needs,
|
||||
user?.modules ?? {},
|
||||
);
|
||||
const back = previousScreen(state, "confirm");
|
||||
|
||||
const glucoseUnit = needs.units.glucoseUnit ?? user?.glucoseUnit ?? null;
|
||||
@@ -73,37 +141,39 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
* after" just created: the route then applies the derivation to THAT
|
||||
* record and stamps this one complete without deriving, so the guardian's
|
||||
* own modules are never re-ordered around the child's answers.
|
||||
*
|
||||
* v1.39 (Wave C, C2) — this RETHROWS. `<BaselineForm>` owns the two
|
||||
* buttons and releases them in its own `finally`, and it can only do that
|
||||
* for a failure it is told about: the previous version caught, toasted and
|
||||
* returned normally, which left both buttons disabled until a reload
|
||||
* (research I10). The two managed-arm callers, which have no form around
|
||||
* them, go through `finishReporting` below.
|
||||
*/
|
||||
async function finish(managedRecordId?: string) {
|
||||
if (finishing) return;
|
||||
setFinishing(true);
|
||||
try {
|
||||
// A question this flow never showed — Q6 for somebody with no
|
||||
// unit-bearing area — is passed on the ledger before the completion,
|
||||
// because the route derives only once EVERY question is answered or
|
||||
// passed, and a step nobody was asked cannot be answered.
|
||||
for (const id of questionsToPassBeforeConfirm(state)) {
|
||||
await answer.mutateAsync({ step: id, status: "skipped" });
|
||||
}
|
||||
const { onboarding } = await complete.mutateAsync(
|
||||
managedRecordId ? { managedRecordId } : undefined,
|
||||
);
|
||||
let written = onboarding ?? state;
|
||||
if (!firstResultApplies(written)) {
|
||||
// Nothing to offer, so the step is passed rather than left owed:
|
||||
// a pending step would keep the setup reading as unfinished forever.
|
||||
written = await answer.mutateAsync({
|
||||
step: "first-result",
|
||||
status: "skipped",
|
||||
});
|
||||
}
|
||||
router.push(screenHref(nextScreen(written, "confirm") ?? "done"));
|
||||
await runConfirmFinish({
|
||||
ledger,
|
||||
managedRecordId,
|
||||
passStep: (step) => answer.mutateAsync({ step, status: "skipped" }),
|
||||
complete: (id) =>
|
||||
complete.mutateAsync(id ? { managedRecordId: id } : undefined),
|
||||
navigate: (href) => router.push(href),
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
|
||||
setFinishing(false);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** The managed arm's two buttons: nothing above them reports a failure. */
|
||||
function finishReporting(managedRecordId?: string) {
|
||||
void finish(managedRecordId).catch((err) => {
|
||||
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
|
||||
});
|
||||
}
|
||||
|
||||
const moduleNames = (keys: readonly (keyof typeof MODULE_REGISTRY)[]) =>
|
||||
keys.map((key) => t(MODULE_REGISTRY[key].labelKey)).join(", ");
|
||||
|
||||
@@ -129,6 +199,13 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
})
|
||||
: t("onboarding.flow.confirm.nothingExtra")}
|
||||
</p>
|
||||
{wouldSwitchOff.length > 0 ? (
|
||||
<p className="text-sm" data-slot="onboarding-confirm-modules-off">
|
||||
{t("onboarding.flow.confirm.switchesOff", {
|
||||
modules: moduleNames(wouldSwitchOff),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("onboarding.flow.confirm.alwaysOn", {
|
||||
modules: moduleNames(alwaysOn),
|
||||
@@ -136,12 +213,18 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("onboarding.flow.confirm.everythingElse")}{" "}
|
||||
<Link
|
||||
href="/settings/modules"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
{t("onboarding.flow.confirm.modulesLink")}
|
||||
</Link>
|
||||
{settingsReachable ? (
|
||||
<Link
|
||||
href="/settings/modules"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
{t("onboarding.flow.confirm.modulesLink")}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium">
|
||||
{t("onboarding.flow.confirm.modulesLink")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -176,12 +259,18 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
) : null}
|
||||
</dl>
|
||||
<p className="text-sm">
|
||||
<Link
|
||||
href="/settings/account"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
{t("onboarding.flow.confirm.changeInSettings")}
|
||||
</Link>
|
||||
{settingsReachable ? (
|
||||
<Link
|
||||
href="/settings/account"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
{t("onboarding.flow.confirm.changeInSettings")}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t("onboarding.flow.confirm.changeInSettings")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{forSomeoneElse ? (
|
||||
@@ -196,7 +285,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
</div>
|
||||
<ManagedProfileCreateForm
|
||||
submitLabel={t("onboarding.flow.confirm.managedCreate")}
|
||||
onCreated={(profile) => void finish(profile.id)}
|
||||
onCreated={(profile) => finishReporting(profile.id)}
|
||||
/>
|
||||
<p className="text-sm">{t("onboarding.flow.confirm.managedLater")}</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-2">
|
||||
@@ -212,7 +301,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void finish()}
|
||||
onClick={() => finishReporting()}
|
||||
disabled={finishing}
|
||||
className="min-h-11"
|
||||
data-slot="onboarding-finish-without-profile"
|
||||
|
||||
@@ -26,6 +26,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
buildChecklist,
|
||||
checklistProgress,
|
||||
isStillInSetup,
|
||||
shouldShowChecklist,
|
||||
upcomingVisitCountFrom,
|
||||
visibleChecklist,
|
||||
@@ -239,17 +240,27 @@ export function GettingStartedChecklist() {
|
||||
}, [snapshotData]);
|
||||
|
||||
// v1.15.20 — only fetch the checklist's supporting data while the card
|
||||
// can actually render: the user is still in setup (`shouldShowChecklist`'s
|
||||
// `onboardingCompletedAt == null || measurementCount < 5` rule) and has
|
||||
// not dismissed it. Established users used to pay this medications fetch
|
||||
// (and, pre-v1.5, two more) on every dashboard load for a card that
|
||||
// never mounts. Gated on the snapshot having resolved so the count is
|
||||
// real, not the loading-default 0.
|
||||
// can actually render: the user is still in setup and has not dismissed
|
||||
// it. Established users used to pay this medications fetch (and, pre-v1.5,
|
||||
// two more) on every dashboard load for a card that never mounts. Gated on
|
||||
// the snapshot having resolved so the count is real, not the
|
||||
// loading-default 0.
|
||||
//
|
||||
// v1.39 (Wave C, C1) — the rule is `isStillInSetup`, the same predicate
|
||||
// `shouldShowChecklist` applies, rather than a restatement of half of it.
|
||||
// The two drifted apart once already: the visibility rule learned about
|
||||
// records that ran the setup flow and this gate did not, so the card
|
||||
// rendered for a wizard-run account past five readings with every
|
||||
// network-backed row frozen at "not done" (research I9, M-p).
|
||||
const checklistRelevant =
|
||||
!!user &&
|
||||
!dismissedAll &&
|
||||
snapshotData !== undefined &&
|
||||
(user.onboardingCompletedAt == null || measurementCount < 5);
|
||||
isStillInSetup({
|
||||
onboarding: user.onboarding ?? null,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt ?? null,
|
||||
measurementCount,
|
||||
});
|
||||
|
||||
const { data: medsData } = useQuery<Array<{ id: string }>>({
|
||||
queryKey: queryKeys.medications(),
|
||||
|
||||
@@ -54,9 +54,15 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) {
|
||||
|
||||
const back = previousScreen(state, "units");
|
||||
|
||||
const answerBody = unitsAnswerBody({ asked, glucoseUnit, unitPreference });
|
||||
|
||||
async function persist(
|
||||
input:
|
||||
ReturnType<typeof unitsAnswerBody> | { step: "units"; status: "skipped" },
|
||||
| NonNullable<ReturnType<typeof unitsAnswerBody>>
|
||||
| {
|
||||
step: "units";
|
||||
status: "skipped";
|
||||
},
|
||||
) {
|
||||
try {
|
||||
const written = await answer.mutateAsync(input);
|
||||
@@ -121,9 +127,10 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) {
|
||||
<StepActions
|
||||
backHref={back ? screenHref(back) : undefined}
|
||||
onSkip={() => void persist({ step: "units", status: "skipped" })}
|
||||
onNext={() =>
|
||||
void persist(unitsAnswerBody({ asked, glucoseUnit, unitPreference }))
|
||||
}
|
||||
onNext={() => {
|
||||
if (answerBody) void persist(answerBody);
|
||||
}}
|
||||
nextDisabled={answerBody === null}
|
||||
pending={answer.isPending}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CHECKLIST_BASE_ITEM_IDS,
|
||||
CHECKLIST_ITEM_IDS,
|
||||
isProfileComplete,
|
||||
isStillInSetup,
|
||||
shouldShowChecklist,
|
||||
trendHintFor,
|
||||
upcomingVisitCountFrom,
|
||||
@@ -608,3 +609,75 @@ describe("shouldShowChecklist and the setup flow", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isStillInSetup", () => {
|
||||
// The one predicate both the visibility rule and the component's query gate
|
||||
// read, so the card can never render with its supporting queries switched
|
||||
// off (research I9).
|
||||
it("is true for a record that ran the flow, whatever its reading count", () => {
|
||||
expect(
|
||||
isStillInSetup({
|
||||
onboarding: onboardingState(),
|
||||
onboardingCompletedAt: "2026-01-01T00:00:00Z",
|
||||
measurementCount: 120,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for a restarted run nobody finished (M-p)", () => {
|
||||
const restarted = onboardingState({ completedAt: null });
|
||||
expect(
|
||||
isStillInSetup({
|
||||
onboarding: restarted,
|
||||
// `restart` deliberately leaves the account stamp alone.
|
||||
onboardingCompletedAt: "2026-01-01T00:00:00Z",
|
||||
measurementCount: 120,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the pre-flow rule for a record that never entered the flow", () => {
|
||||
const untouched: OnboardingStateDto = {
|
||||
steps: defaultOnboardingSteps(),
|
||||
needs: emptyOnboardingNeeds(),
|
||||
completedAt: null,
|
||||
firstResult: null,
|
||||
};
|
||||
expect(
|
||||
isStillInSetup({
|
||||
onboarding: untouched,
|
||||
onboardingCompletedAt: "2026-01-01T00:00:00Z",
|
||||
measurementCount: 10,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isStillInSetup({
|
||||
onboarding: untouched,
|
||||
onboardingCompletedAt: "2026-01-01T00:00:00Z",
|
||||
measurementCount: 4,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStillInSetup({
|
||||
onboarding: null,
|
||||
onboardingCompletedAt: null,
|
||||
measurementCount: 10,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is the rule `shouldShowChecklist` itself applies", () => {
|
||||
const args = {
|
||||
onboarding: onboardingState(),
|
||||
onboardingCompletedAt: "2026-01-01T00:00:00Z",
|
||||
measurementCount: 120,
|
||||
};
|
||||
expect(
|
||||
shouldShowChecklist({
|
||||
...args,
|
||||
dismissedAll: false,
|
||||
items: buildChecklist(inputs({ measurementCount: 120 })),
|
||||
}),
|
||||
).toBe(isStillInSetup(args));
|
||||
});
|
||||
});
|
||||
@@ -66,3 +66,62 @@ describe("confirmedModules", () => {
|
||||
).toContain("vaccinations");
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmedModules — what leaves the navigation (I6)", () => {
|
||||
const noMedication = {
|
||||
...emptyOnboardingNeeds(),
|
||||
recordTarget: "me" as const,
|
||||
areas: ["sleep" as const],
|
||||
medication: "no" as const,
|
||||
visit: "no" as const,
|
||||
};
|
||||
|
||||
it("names a module the answers do not ask for and the record shows today", () => {
|
||||
const { wouldSwitchOff } = confirmedModules(noMedication, {
|
||||
medications: true,
|
||||
doctorReport: true,
|
||||
});
|
||||
expect(wouldSwitchOff).toContain("medications");
|
||||
expect(wouldSwitchOff).toContain("doctorReport");
|
||||
});
|
||||
|
||||
it("never names a module the answers switch on", () => {
|
||||
const { chosen, wouldSwitchOff } = confirmedModules(
|
||||
{ ...noMedication, medication: "yes" },
|
||||
{ medications: true },
|
||||
);
|
||||
expect(chosen).toContain("medications");
|
||||
expect(wouldSwitchOff).not.toContain("medications");
|
||||
});
|
||||
|
||||
it("never names a module the record does not show today", () => {
|
||||
const { wouldSwitchOff } = confirmedModules(noMedication, {
|
||||
medications: false,
|
||||
});
|
||||
expect(wouldSwitchOff).not.toContain("medications");
|
||||
// Nothing is known about a module the payload does not carry, so the
|
||||
// screen says nothing about it.
|
||||
expect(confirmedModules(noMedication, {}).wouldSwitchOff).toEqual([]);
|
||||
});
|
||||
|
||||
it("never names an always-on module, whatever the answers say", () => {
|
||||
const current = Object.fromEntries(
|
||||
ONBOARDING_ALWAYS_ON_MODULES.map((key) => [key, true]),
|
||||
);
|
||||
const { wouldSwitchOff } = confirmedModules(noMedication, current);
|
||||
for (const key of ONBOARDING_ALWAYS_ON_MODULES) {
|
||||
expect(wouldSwitchOff).not.toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("is in registry order, like the list beside it", () => {
|
||||
const current = Object.fromEntries(
|
||||
OWNED_MODULE_KEYS.map((key) => [key, true]),
|
||||
);
|
||||
const { wouldSwitchOff } = confirmedModules(noMedication, current);
|
||||
expect(wouldSwitchOff).toEqual(
|
||||
OWNED_MODULE_KEYS.filter((key) => wouldSwitchOff.includes(key)),
|
||||
);
|
||||
expect(wouldSwitchOff.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -80,11 +80,15 @@ describe("questionAnswerBody", () => {
|
||||
expect(questionAnswerBody("visit", [])).toBeNull();
|
||||
});
|
||||
|
||||
it("sends an empty list for a many-answer question with nothing chosen", () => {
|
||||
expect(questionAnswerBody("areas", [])).toEqual({
|
||||
step: "areas",
|
||||
areas: [],
|
||||
});
|
||||
it("is null for a many-answer question with nothing chosen (I4)", () => {
|
||||
// An empty tick list is not an opinion. It used to be sent as an
|
||||
// ANSWER, and an answered Q2 with no areas is what switched every
|
||||
// optional module off for somebody who meant "no preference" — Skip is
|
||||
// the way past, and Skip is the conservative derivation.
|
||||
expect(questionAnswerBody("areas", [])).toBeNull();
|
||||
expect(questionAnswerBody("sources", [])).toBeNull();
|
||||
// A list of values none of which the step offers is just as empty.
|
||||
expect(questionAnswerBody("areas", ["no-such-area"])).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a value the step's vocabulary does not contain", () => {
|
||||
@@ -125,13 +129,22 @@ describe("units", () => {
|
||||
expect(onboardingAnswerSchema.safeParse(body).success).toBe(true);
|
||||
});
|
||||
|
||||
it("sends an empty answer when nothing was chosen, which leaves the columns alone", () => {
|
||||
it("is null when nothing was chosen, so Skip is the way past (M-k)", () => {
|
||||
// An empty units answer marked Q6 done without recording a preference.
|
||||
expect(
|
||||
unitsAnswerBody({
|
||||
asked: { glucose: true, weight: true },
|
||||
glucoseUnit: null,
|
||||
unitPreference: null,
|
||||
}),
|
||||
).toEqual({ step: "units", units: {} });
|
||||
).toBeNull();
|
||||
// One of two asked questions answered is still an answer.
|
||||
expect(
|
||||
unitsAnswerBody({
|
||||
asked: { glucose: true, weight: true },
|
||||
glucoseUnit: "mg/dL",
|
||||
unitPreference: null,
|
||||
}),
|
||||
).toEqual({ step: "units", units: { glucoseUnit: "mg/dL" } });
|
||||
});
|
||||
});
|
||||
@@ -282,11 +282,7 @@ export function checklistProgress(items: ChecklistItem[]): ChecklistProgress {
|
||||
* unfinished, because the one task the flow offered is not one of the six
|
||||
* rows: "every row done" and "the setup finished" are different statements.
|
||||
*/
|
||||
export function shouldShowChecklist(args: {
|
||||
onboardingCompletedAt: string | null;
|
||||
measurementCount: number;
|
||||
dismissedAll: boolean;
|
||||
items: ChecklistItem[];
|
||||
export interface StillInSetupInputs {
|
||||
/**
|
||||
* v1.39 (C1) — the needs-based setup state for this record, or null before
|
||||
* the account payload resolves. A record that never entered the flow reads
|
||||
@@ -295,17 +291,45 @@ export function shouldShowChecklist(args: {
|
||||
* on the rule it already had.
|
||||
*/
|
||||
onboarding?: OnboardingStateDto | null;
|
||||
}): boolean {
|
||||
onboardingCompletedAt: string | null;
|
||||
measurementCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39 (Wave C, C1) — "is this record still being set up?", as ONE predicate.
|
||||
*
|
||||
* Two places decide this: whether the card renders at all, and whether the
|
||||
* component fetches the data its rows are about. They were two restatements of
|
||||
* the same rule and they drifted — the visibility rule gained
|
||||
* `hasEnteredOnboardingFlow`, the query gate kept the older half, and a
|
||||
* wizard-run account past five readings got a permanently visible card whose
|
||||
* every network-backed row read "not done" (research I9). A record whose
|
||||
* restarted run was abandoned is in the same state (M-p): `restart` clears the
|
||||
* record's own `completedAt` and deliberately not the account stamp, so
|
||||
* `hasEnteredOnboardingFlow` is what keeps its rows live.
|
||||
*
|
||||
* Exported so the component calls it rather than restating it.
|
||||
*/
|
||||
export function isStillInSetup(args: StillInSetupInputs): boolean {
|
||||
return (
|
||||
hasEnteredOnboardingFlow(args.onboarding ?? null) ||
|
||||
args.onboardingCompletedAt == null ||
|
||||
args.measurementCount < 5
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowChecklist(
|
||||
args: StillInSetupInputs & {
|
||||
dismissedAll: boolean;
|
||||
items: ChecklistItem[];
|
||||
},
|
||||
): boolean {
|
||||
if (args.dismissedAll) return false;
|
||||
const onboarding = args.onboarding ?? null;
|
||||
const visible = visibleChecklist(args.items);
|
||||
if (visible.length === 0) return false;
|
||||
|
||||
const stillInSetup =
|
||||
hasEnteredOnboardingFlow(onboarding) ||
|
||||
args.onboardingCompletedAt == null ||
|
||||
args.measurementCount < 5;
|
||||
if (!stillInSetup) return false;
|
||||
if (!isStillInSetup(args)) return false;
|
||||
|
||||
if (!isOnboardingSettled(onboarding)) return true;
|
||||
return visible.some((item) => !item.done);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import {
|
||||
deriveOnboardingModuleDefaults,
|
||||
mergeDerivedModulePreferences,
|
||||
MODULE_KEYS,
|
||||
ONBOARDING_ALWAYS_ON_MODULES,
|
||||
type ModuleKey,
|
||||
@@ -27,9 +28,24 @@ export interface ConfirmedModules {
|
||||
chosen: ModuleKey[];
|
||||
/** On whatever the answers say. */
|
||||
alwaysOn: ModuleKey[];
|
||||
/**
|
||||
* v1.39 (Wave C, C4) — what the derivation takes OUT of the navigation:
|
||||
* modules this record shows today that the answers do not ask for
|
||||
* (research I6). Empty until the account payload has resolved, because a
|
||||
* module the payload says nothing about is a module this screen knows
|
||||
* nothing about.
|
||||
*/
|
||||
wouldSwitchOff: ModuleKey[];
|
||||
}
|
||||
|
||||
export function confirmedModules(needs: OnboardingNeeds): ConfirmedModules {
|
||||
/**
|
||||
* @param current the module map the record shows today (`user.modules`), so
|
||||
* the screen names only modules that are actually in the navigation now.
|
||||
*/
|
||||
export function confirmedModules(
|
||||
needs: OnboardingNeeds,
|
||||
current: Partial<Record<ModuleKey, boolean>> = {},
|
||||
): ConfirmedModules {
|
||||
const derived = deriveOnboardingModuleDefaults({
|
||||
// Q1 is required and a confirm screen is never reached without it; the
|
||||
// fallback only keeps the type total.
|
||||
@@ -45,8 +61,27 @@ export function confirmedModules(needs: OnboardingNeeds): ConfirmedModules {
|
||||
}
|
||||
if (derived.cycleTracking) on.add("cycle");
|
||||
const alwaysOn = new Set<ModuleKey>(ONBOARDING_ALWAYS_ON_MODULES);
|
||||
// The registry's own merge rule decides what a derivation puts at `false`,
|
||||
// so this screen never restates it. It is asked with nothing to spare —
|
||||
// no stored preference, no domain holding rows — because that is the
|
||||
// record the confirm screen is looking at on a first run, and it is the
|
||||
// maximum the completion can take away. What the merge would spare on a
|
||||
// re-run (a module switched on by hand, one holding data) it spares
|
||||
// server-side, so this list can only ever be longer than what happens,
|
||||
// never shorter, and the sentence beside it says how to get one back.
|
||||
const afterDerivation = mergeDerivedModulePreferences(
|
||||
{},
|
||||
derived.preferences,
|
||||
new Set<string>(),
|
||||
);
|
||||
return {
|
||||
chosen: MODULE_KEYS.filter((key) => on.has(key) && !alwaysOn.has(key)),
|
||||
alwaysOn: MODULE_KEYS.filter((key) => alwaysOn.has(key)),
|
||||
wouldSwitchOff: MODULE_KEYS.filter(
|
||||
(key) =>
|
||||
!alwaysOn.has(key) &&
|
||||
current[key] === true &&
|
||||
afterDerivation[key] === false,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -110,9 +110,15 @@ function only<T extends string>(
|
||||
|
||||
/**
|
||||
* The body for one answered step, or null when the selection cannot be an
|
||||
* answer (Q1 with nothing chosen). Every arm names its field; a value that is
|
||||
* not in the step's vocabulary is dropped rather than sent, so the strict
|
||||
* server schema never sees a chip this file did not offer.
|
||||
* answer. Every arm names its field; a value that is not in the step's
|
||||
* vocabulary is dropped rather than sent, so the strict server schema never
|
||||
* sees a chip this file did not offer.
|
||||
*
|
||||
* v1.39 (Wave C, C3) — an empty tick list is not an answer either (research
|
||||
* I4). The many-answer arms used to send `[]` and mark the step DONE, and an
|
||||
* answered Q2 with no areas is what switched every optional module off for
|
||||
* somebody whose click meant "no preference". Skip is the way past a question
|
||||
* nobody wants to answer, and Skip is the conservative derivation.
|
||||
*/
|
||||
export function questionAnswerBody(
|
||||
step: QuestionStepId,
|
||||
@@ -123,14 +129,18 @@ export function questionAnswerBody(
|
||||
const [recordTarget] = only(ONBOARDING_RECORD_TARGETS, selected);
|
||||
return recordTarget ? { step, recordTarget } : null;
|
||||
}
|
||||
case "areas":
|
||||
return { step, areas: only(ONBOARDING_AREA_KEYS, selected) };
|
||||
case "areas": {
|
||||
const areas = only(ONBOARDING_AREA_KEYS, selected);
|
||||
return areas.length > 0 ? { step, areas } : null;
|
||||
}
|
||||
case "medication": {
|
||||
const [medication] = only(ONBOARDING_MEDICATION_ANSWERS, selected);
|
||||
return medication ? { step, medication } : null;
|
||||
}
|
||||
case "sources":
|
||||
return { step, sources: only(ONBOARDING_SOURCE_KEYS, selected) };
|
||||
case "sources": {
|
||||
const sources = only(ONBOARDING_SOURCE_KEYS, selected);
|
||||
return sources.length > 0 ? { step, sources } : null;
|
||||
}
|
||||
case "visit": {
|
||||
const [visit] = only(ONBOARDING_VISIT_ANSWERS, selected);
|
||||
return visit ? { step, visit } : null;
|
||||
@@ -171,7 +181,7 @@ export function unitsAnswerBody(input: {
|
||||
asked: { glucose: boolean; weight: boolean };
|
||||
glucoseUnit: "mg/dL" | "mmol/L" | null;
|
||||
unitPreference: "metric" | "imperial" | null;
|
||||
}): OnboardingAnswerInput {
|
||||
}): OnboardingAnswerInput | null {
|
||||
const units: {
|
||||
glucoseUnit?: "mg/dL" | "mmol/L";
|
||||
unitPreference?: "metric" | "imperial";
|
||||
@@ -182,5 +192,7 @@ export function unitsAnswerBody(input: {
|
||||
if (input.asked.weight && input.unitPreference) {
|
||||
units.unitPreference = input.unitPreference;
|
||||
}
|
||||
return { step: "units", units };
|
||||
// v1.39 (Wave C, C3) — nothing chosen is not an answer (M-k): an empty
|
||||
// units body marked Q6 done while recording no preference at all.
|
||||
return Object.keys(units).length > 0 ? { step: "units", units } : null;
|
||||
}
|
||||
@@ -174,6 +174,19 @@ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> =
|
||||
// Neither creates health data.
|
||||
{ path: "/api/onboarding/disclaimer", method: "POST" },
|
||||
{ path: "/api/onboarding/tour", method: "POST" },
|
||||
// v1.39 (Wave C, C6) — the three writes the confirm screen makes. Without
|
||||
// them a demo visitor who typed a height got a 403 and a generic toast,
|
||||
// and the "someone I look after" arm could only take "Finish without the
|
||||
// profile" — only the empty-form/Skip path completed, so the demo did not
|
||||
// in fact walk the same flow (design spec §Principles 5). None of the
|
||||
// three creates health data: the caller's own profile fields, the
|
||||
// encrypted self-context behind the optional anamnesis card, and the
|
||||
// managed record the guardian arm of the flow exists to create. The
|
||||
// per-profile routes (`/api/managed-profiles/{id}`, its guardians) are
|
||||
// different paths and stay closed.
|
||||
{ path: "/api/auth/profile", method: "PUT" },
|
||||
{ path: "/api/coach/about-me", method: "PUT" },
|
||||
{ path: "/api/managed-profiles", method: "POST" },
|
||||
];
|
||||
|
||||
// Legacy route redirects (German → English)
|
||||
|
||||
Reference in new issue
Block a user