From e9e4a4b44d6abe934f30ea10523e64747cb86196 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 11 Sep 2026 09:24:36 +0200
Subject: [PATCH] fix(onboarding): confirm names the modules leaving the nav
The screen listed what the answers switch on and said everything else
stays one click away under Settings, Modules. That is true of
reachability and false of the navigation entry: a module the answers do
not ask for is written off by the completion's derivation.
It now names those modules, read through the registry's own merge rule
and intersected with the map the navigation shows today, so nothing is
claimed about a module the account payload says nothing about.
---
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/ko.json | 1 +
messages/pl.json | 1 +
.../__tests__/confirm-screen.test.tsx | 86 +++++++++++++++++++
src/components/onboarding/confirm-screen.tsx | 14 ++-
.../__tests__/confirm-summary.test.ts | 59 +++++++++++++
src/lib/onboarding/confirm-summary.ts | 37 +++++++-
11 files changed, 201 insertions(+), 2 deletions(-)
create mode 100644 src/components/onboarding/__tests__/confirm-screen.test.tsx
diff --git a/messages/de.json b/messages/de.json
index bc23d2f08..a7c65acce 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4661,6 +4661,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",
diff --git a/messages/en.json b/messages/en.json
index dc66aa61b..158f6180d 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4661,6 +4661,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",
diff --git a/messages/es.json b/messages/es.json
index e5fddd66f..e631287b2 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4661,6 +4661,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",
diff --git a/messages/fr.json b/messages/fr.json
index 1640aa7e9..4ea236315 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4661,6 +4661,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",
diff --git a/messages/it.json b/messages/it.json
index d965b3827..647b502a0 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4661,6 +4661,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",
diff --git a/messages/ko.json b/messages/ko.json
index 4dabc847b..2f6d640e2 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -4661,6 +4661,7 @@
"alwaysOn": "항상 켜짐: {modules}, 그리고 기본 측정값(체중, 혈압, 맥박).",
"everythingElse": "나머지는 한 번의 클릭으로 열 수 있습니다:",
"modulesLink": "설정, 모듈",
+ "switchesOff": "탐색에서 빠집니다: {modules}. 설정, 모듈에서 언제든 다시 켤 수 있습니다.",
"glucoseUnit": "혈당",
"unitPreference": "단위",
"language": "언어",
diff --git a/messages/pl.json b/messages/pl.json
index 9769c0838..972235a6a 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4661,6 +4661,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",
diff --git a/src/components/onboarding/__tests__/confirm-screen.test.tsx b/src/components/onboarding/__tests__/confirm-screen.test.tsx
new file mode 100644
index 000000000..bef1dc2ae
--- /dev/null
+++ b/src/components/onboarding/__tests__/confirm-screen.test.tsx
@@ -0,0 +1,86 @@
+/**
+ * v1.39 (Wave C) — what the confirm screen is allowed to claim.
+ *
+ * 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 = {},
+ needs: Partial = {},
+): OnboardingStateDto {
+ return {
+ steps: defaultOnboardingSteps(),
+ needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs },
+ completedAt: null,
+ firstResult: null,
+ ...overrides,
+ };
+}
+
+function render(
+ flow: OnboardingStateDto,
+ modules: Partial> = {},
+): string {
+ currentUser = {
+ id: "u1",
+ heightCm: null,
+ dateOfBirth: null,
+ gender: null,
+ timezone: "Europe/Berlin",
+ unitPreference: "metric",
+ modules,
+ } as unknown as AuthUser;
+ return renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+}
+
+describe(" — 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"');
+ });
+});
diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx
index 8f55be3a5..afee75e5d 100644
--- a/src/components/onboarding/confirm-screen.tsx
+++ b/src/components/onboarding/confirm-screen.tsx
@@ -112,7 +112,12 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
const needs = state.needs;
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;
@@ -182,6 +187,13 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
})
: t("onboarding.flow.confirm.nothingExtra")}
+ {wouldSwitchOff.length > 0 ? (
+
+ {t("onboarding.flow.confirm.switchesOff", {
+ modules: moduleNames(wouldSwitchOff),
+ })}
+
+ ) : null}
{t("onboarding.flow.confirm.alwaysOn", {
modules: moduleNames(alwaysOn),
diff --git a/src/lib/onboarding/__tests__/confirm-summary.test.ts b/src/lib/onboarding/__tests__/confirm-summary.test.ts
index 3ced0603a..88acc03dd 100644
--- a/src/lib/onboarding/__tests__/confirm-summary.test.ts
+++ b/src/lib/onboarding/__tests__/confirm-summary.test.ts
@@ -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);
+ });
+});
diff --git a/src/lib/onboarding/confirm-summary.ts b/src/lib/onboarding/confirm-summary.ts
index 1dfb28c9b..260b2faef 100644
--- a/src/lib/onboarding/confirm-summary.ts
+++ b/src/lib/onboarding/confirm-summary.ts
@@ -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> = {},
+): 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(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(),
+ );
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,
+ ),
};
}