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, + ), }; }