mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
This commit is contained in:
1 parent
77a09a5080
commit
e9e4a4b44d
11 files changed
+201
-2
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4661,6 +4661,7 @@
|
||||
"alwaysOn": "항상 켜짐: {modules}, 그리고 기본 측정값(체중, 혈압, 맥박).",
|
||||
"everythingElse": "나머지는 한 번의 클릭으로 열 수 있습니다:",
|
||||
"modulesLink": "설정, 모듈",
|
||||
"switchesOff": "탐색에서 빠집니다: {modules}. 설정, 모듈에서 언제든 다시 켤 수 있습니다.",
|
||||
"glucoseUnit": "혈당",
|
||||
"unitPreference": "단위",
|
||||
"language": "언어",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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"');
|
||||
});
|
||||
});
|
||||
@@ -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")}
|
||||
</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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user