diff --git a/messages/de.json b/messages/de.json index 9252f62a7..b54aa1a08 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2534,9 +2534,9 @@ "label": "Aktivität", "header": "Aktivität & Mobilität" }, - "cardiovascularParent": { - "label": "Herz-Kreislauf", - "header": "Herz-Kreislauf-Marker" + "heartParent": { + "label": "Herz", + "header": "Herzgesundheit" }, "hearingParent": { "label": "Gehör", @@ -4192,7 +4192,16 @@ "disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen." }, "shell": { - "sectionsNav": "Einstellungs-Bereiche" + "sectionsNav": "Einstellungs-Bereiche", + "groups": { + "account": "Konto", + "yourData": "Deine Daten", + "display": "Anzeige", + "healthProfile": "Gesundheitsprofil", + "ai": "KI", + "connectivity": "Konnektivität", + "system": "System" + } }, "sections": { "account": { diff --git a/messages/en.json b/messages/en.json index be96b60ab..d24bcfc2d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2534,9 +2534,9 @@ "label": "Activity", "header": "Activity & mobility" }, - "cardiovascularParent": { - "label": "Cardiovascular", - "header": "Cardiovascular markers" + "heartParent": { + "label": "Heart", + "header": "Heart health" }, "hearingParent": { "label": "Hearing", @@ -4192,7 +4192,16 @@ "disableError": "Could not disable Research Mode. Please try again." }, "shell": { - "sectionsNav": "Settings sections" + "sectionsNav": "Settings sections", + "groups": { + "account": "Account", + "yourData": "Your data", + "display": "Display", + "healthProfile": "Health profile", + "ai": "AI", + "connectivity": "Connectivity", + "system": "System" + } }, "sections": { "account": { diff --git a/messages/es.json b/messages/es.json index df1777603..ce0861be5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2534,9 +2534,9 @@ "label": "Actividad", "header": "Actividad y movilidad" }, - "cardiovascularParent": { - "label": "Cardiovascular", - "header": "Marcadores cardiovasculares" + "heartParent": { + "label": "Corazón", + "header": "Salud cardíaca" }, "hearingParent": { "label": "Audición", @@ -4192,7 +4192,16 @@ "disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo." }, "shell": { - "sectionsNav": "Secciones de ajustes" + "sectionsNav": "Secciones de ajustes", + "groups": { + "account": "Cuenta", + "yourData": "Tus datos", + "display": "Visualización", + "healthProfile": "Perfil de salud", + "ai": "IA", + "connectivity": "Conectividad", + "system": "Sistema" + } }, "sections": { "account": { diff --git a/messages/fr.json b/messages/fr.json index 945051dc9..4dfd3c433 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2534,9 +2534,9 @@ "label": "Activité", "header": "Activité et mobilité" }, - "cardiovascularParent": { - "label": "Cardiovasculaire", - "header": "Marqueurs cardiovasculaires" + "heartParent": { + "label": "Cœur", + "header": "Santé cardiaque" }, "hearingParent": { "label": "Audition", @@ -4192,7 +4192,16 @@ "disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer." }, "shell": { - "sectionsNav": "Sections des réglages" + "sectionsNav": "Sections des réglages", + "groups": { + "account": "Compte", + "yourData": "Vos données", + "display": "Affichage", + "healthProfile": "Profil de santé", + "ai": "IA", + "connectivity": "Connectivité", + "system": "Système" + } }, "sections": { "account": { diff --git a/messages/it.json b/messages/it.json index 44e478b3b..3a34e2792 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2534,9 +2534,9 @@ "label": "Attività", "header": "Attività e mobilità" }, - "cardiovascularParent": { - "label": "Cardiovascolare", - "header": "Marcatori cardiovascolari" + "heartParent": { + "label": "Cuore", + "header": "Salute del cuore" }, "hearingParent": { "label": "Udito", @@ -4192,7 +4192,16 @@ "disableError": "Impossibile disattivare la modalità Ricerca. Riprova." }, "shell": { - "sectionsNav": "Sezioni delle impostazioni" + "sectionsNav": "Sezioni delle impostazioni", + "groups": { + "account": "Account", + "yourData": "I tuoi dati", + "display": "Visualizzazione", + "healthProfile": "Profilo sanitario", + "ai": "IA", + "connectivity": "Connettività", + "system": "Sistema" + } }, "sections": { "account": { diff --git a/messages/pl.json b/messages/pl.json index 2a51daa76..51652f638 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2534,9 +2534,9 @@ "label": "Aktywność", "header": "Aktywność i mobilność" }, - "cardiovascularParent": { - "label": "Sercowo-naczyniowe", - "header": "Markery sercowo-naczyniowe" + "heartParent": { + "label": "Serce", + "header": "Zdrowie serca" }, "hearingParent": { "label": "Słuch", @@ -4192,7 +4192,16 @@ "disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie." }, "shell": { - "sectionsNav": "Sekcje ustawień" + "sectionsNav": "Sekcje ustawień", + "groups": { + "account": "Konto", + "yourData": "Twoje dane", + "display": "Wyświetlanie", + "healthProfile": "Profil zdrowotny", + "ai": "AI", + "connectivity": "Łączność", + "system": "System" + } }, "sections": { "account": { diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx index aa6b0be7a..9cda95039 100644 --- a/src/app/insights/catalog/page.tsx +++ b/src/app/insights/catalog/page.tsx @@ -78,7 +78,7 @@ const GROUP_ICONS: Record = { body: Scale, activity: Footprints, sleep: Moon, - cardiovascular: HeartPulse, + heart: HeartPulse, hearing: Ear, environment: Sun, metabolic: Droplet, diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index 051a4c8e3..d8e103e8a 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -70,10 +70,11 @@ describe(" — availability gating (v1.4.27 F19)", () => { hasMedication: false, }; const html = render(); - // Pills that have data stay. - expect(html).toContain(">Pulse<"); + // Pills that have data stay — pulse and bmi surface through their + // Heart / Body groups (2026-07-17 UX/IA audit M1). + expect(html).toContain('data-group="heart"'); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); + expect(html).toContain('data-group="body"'); // Pills without data drop. expect(html).not.toContain(">Blood Pressure<"); expect(html).not.toContain(">Mood<"); @@ -143,7 +144,8 @@ describe(" — availability gating (v1.4.27 F19)", () => { }; const html = render(); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); + // bmi surfaces through the Body group (2026-07-17 UX/IA audit M1). + expect(html).toContain('data-group="body"'); }); }); @@ -221,7 +223,7 @@ describe(" — vitals group collapse (v1.4.34 IW-D)", () => { }; const html = render(); expect(html).not.toContain(">Vitals<"); - expect(html).not.toContain('data-slot="insights-tab-strip-group"'); + expect(html).not.toContain('data-group="vitals"'); }); it("renders the parent pill as a popover trigger (button, not link)", () => { @@ -230,17 +232,80 @@ describe(" — vitals group collapse (v1.4.34 IW-D)", () => { expect(html).toContain('data-group="vitals"'); }); - it("preserves the non-grouped pills inline", () => { + it("preserves the pinned flat pills inline", () => { const html = render(); - // Flat (non-grouped) pills must still be reachable directly. + // Pinned pills must still be reachable directly. expect(html).toContain(">Blood Pressure<"); - expect(html).toContain(">Pulse<"); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); expect(html).toContain(">Sleep<"); expect(html).toContain(">Mood<"); expect(html).toContain(">Medication<"); - expect(html).toContain(">Workouts<"); + }); +}); + +describe(" — heart group collapse (2026-07-17 UX/IA audit H2)", () => { + // Pulse, resting HR, HRV and cardio recovery used to be scattered across + // a flat "Pulse" pill and the "Vitals" popover; they now share one + // "Heart" group. Blood pressure stays pinned flat (see the pinned-pills + // test above). + const heartAvailability: InsightInputs = { + summaries: { + PULSE: fakeSummary(5), + HEART_RATE_VARIABILITY: fakeSummary(5), + RESTING_HEART_RATE: fakeSummary(5), + CARDIO_RECOVERY: fakeSummary(5), + }, + hasMood: false, + hasMedication: false, + }; + + it("renders a single 'Heart' parent pill instead of flat Pulse / HRV / Resting HR / Cardio recovery pills", () => { + const html = render(); + const parentMatches = html.match(/>HeartPulse<"); + expect(html).not.toContain(">HRV<"); + expect(html).not.toContain(">Resting HR<"); + expect(html).not.toContain(">Cardio Recovery<"); + }); + + it("hides the Heart parent pill when no heart metric has data", () => { + const availability: InsightInputs = { + summaries: { WEIGHT: fakeSummary(2) }, + hasMood: false, + hasMedication: false, + }; + const html = render(); + expect(html).not.toContain(">Heart<"); + expect(html).not.toContain('data-group="heart"'); + }); +}); + +describe(" — bmi / workouts collapse into their groups (2026-07-17 UX/IA audit M1)", () => { + it("bmi renders inside the 'Body' popover, not as a flat pill", () => { + const availability: InsightInputs = { + summaries: { WEIGHT: fakeSummary(5) }, + hasMood: false, + hasMedication: false, + }; + const html = render(); + expect(html).toContain(">Weight<"); + expect(html).not.toContain(">BMI<"); + expect(html).toContain('data-group="body"'); + }); + + it("workouts renders inside the 'Activity' popover, not as a flat pill", () => { + const availability: InsightInputs = { + summaries: {}, + hasMood: false, + hasMedication: false, + hasWorkouts: true, + }; + const html = render(); + expect(html).not.toContain(">Workouts<"); + expect(html).toContain('data-group="activity"'); }); }); @@ -255,8 +320,10 @@ describe(" — saved-layout visibility gate (v1.15.14 W2)", () }; it("hides a pill whose slug is layout-hidden even though it has data", () => { - // `pulse` has data but is NOT in the visible set ⇒ no pill. `weight` - // has data AND is visible ⇒ pill shows. + // `pulse` has data but is NOT in the visible set ⇒ its Heart group + // never emits. `weight` has data AND is visible ⇒ pill shows. `bmi` + // has data (derived from WEIGHT) AND is visible ⇒ its Body group + // emits (2026-07-17 UX/IA audit M1 — bmi collapsed out of the flat row). const visibleTileIds = new Set(["overview", "weight", "bmi"]); const html = render( — saved-layout visibility gate (v1.15.14 W2)", () />, ); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); - expect(html).not.toContain(">Pulse<"); + expect(html).toContain('data-group="body"'); + expect(html).not.toContain('data-group="heart"'); }); - it("shows a pill when its slug is layout-visible AND has data", () => { + it("shows a group parent pill when its slug is layout-visible AND has data", () => { const visibleTileIds = new Set(["overview", "pulse", "weight", "bmi"]); const html = render( — saved-layout visibility gate (v1.15.14 W2)", () visibleTileIds={visibleTileIds} />, ); - expect(html).toContain(">Pulse<"); + expect(html).toContain('data-group="heart"'); expect(html).toContain(">Weight<"); }); @@ -302,9 +369,9 @@ describe(" — saved-layout visibility gate (v1.15.14 W2)", () it("falls back to the data-only gate when no layout is loaded (visibleTileIds undefined)", () => { // No visibleTileIds prop ⇒ pre-W2 behaviour: every data-having pill shows. const html = render(); - expect(html).toContain(">Pulse<"); + expect(html).toContain('data-group="heart"'); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); + expect(html).toContain('data-group="body"'); }); it("always shows the Overview pill regardless of layout visibility", () => { @@ -316,7 +383,7 @@ describe(" — saved-layout visibility gate (v1.15.14 W2)", () />, ); expect(html).toContain(">Overview<"); - expect(html).not.toContain(">Pulse<"); + expect(html).not.toContain('data-group="heart"'); expect(html).not.toContain(">Weight<"); }); @@ -405,7 +472,9 @@ describe(" — module enable/disable gate (v1.18.0)", () => { // A pill belonging to a toggleable module (mood / sleep / glucose / // workouts) is hidden when that module is disabled in the account's // resolved module map — on TOP of the data + layout gates. Core metric - // pills (BP, pulse, weight, BMI …) carry no module key and ignore it. + // pills (BP, pulse, weight, BMI …) carry no module key and ignore it — + // pulse and bmi now surface through their Heart / Body group instead of + // a flat pill, but the module gate still never touches them. const moduleAvailability: InsightInputs = { summaries: { PULSE: fakeSummary(5), @@ -418,10 +487,11 @@ describe(" — module enable/disable gate (v1.18.0)", () => { hasWorkouts: true, }; - it("hides the Mood / Sleep / Workouts pills + the glucose (Metabolic) group when their modules are disabled", () => { + it("hides the Mood / Sleep / Activity(workouts) pills/groups + the glucose (Metabolic) group when their modules are disabled", () => { // `blood-glucose` is the sole metabolic metric with data here, so its // module gate collapses the whole "Metabolic" group parent pill — - // mood / sleep / workouts are flat pills that drop directly. + // mood / sleep are flat pills that drop directly; workouts is the + // sole Activity-group member here, so disabling it collapses Activity. const html = render( — module enable/disable gate (v1.18.0)", () => { ); expect(html).not.toContain(">Mood<"); expect(html).not.toContain(">Sleep<"); - expect(html).not.toContain(">Workouts<"); + expect(html).not.toContain('data-group="activity"'); expect(html).not.toContain('data-group="metabolic"'); - // Core metric pills are unaffected by the module map. - expect(html).toContain(">Pulse<"); + // Core metric pills are unaffected by the module map — pulse and bmi + // carry no module key and still surface through their Heart / Body + // groups (2026-07-17 UX/IA audit — both collapsed out of the flat row). + expect(html).toContain('data-group="heart"'); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); + expect(html).toContain('data-group="body"'); expect(html).toContain(">Overview<"); }); @@ -458,7 +530,8 @@ describe(" — module enable/disable gate (v1.18.0)", () => { ); expect(html).toContain(">Mood<"); expect(html).toContain(">Sleep<"); - expect(html).toContain(">Workouts<"); + // Workouts collapses into the Activity group (2026-07-17 UX/IA audit M1). + expect(html).toContain('data-group="activity"'); expect(html).toContain('data-group="metabolic"'); }); @@ -486,7 +559,7 @@ describe(" — module enable/disable gate (v1.18.0)", () => { ); expect(html).toContain(">Mood<"); expect(html).toContain(">Sleep<"); - expect(html).toContain(">Workouts<"); + expect(html).toContain('data-group="activity"'); expect(html).toContain('data-group="metabolic"'); expect(html).toContain(">Recovery<"); } diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 6299c83f8..d7f4b6f61 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -23,6 +23,7 @@ import { cn } from "@/lib/utils"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { INSIGHTS_OVERVIEW_PATH, + PINNED_SUB_PAGE_SLUGS, SUB_PAGE_GROUP, SUB_PAGE_GROUP_ORDER, SUB_PAGE_SLUGS, @@ -395,10 +396,12 @@ function isSlugModuleEnabled( /** * v1.4.34 IW-D — group metadata (label + popover header) keyed by * `SubPageGroup`. The strip renders one parent pill per group. v1.7.0 - * adds the body / activity / cardiovascular / hearing / environment / - * metabolic clusters as the previously-orphan metrics each get a - * sub-page; collapsing them behind a parent pill keeps the strip - * scannable. + * adds the body / activity / hearing / environment / metabolic clusters + * as the previously-orphan metrics each get a sub-page; collapsing them + * behind a parent pill keeps the strip scannable. The 2026-07-17 UX/IA + * audit (H2) replaced the old "Cardiovascular" group — which only ever + * held three rare Withings markers — with a unified "Heart" group that + * also carries pulse, resting HR, HRV and cardio recovery. */ const SUB_PAGE_GROUP_META: Record< SubPageGroup, @@ -416,9 +419,9 @@ const SUB_PAGE_GROUP_META: Record< labelKey: "insights.tabStrip.activityParent.label", headerKey: "insights.tabStrip.activityParent.header", }, - cardiovascular: { - labelKey: "insights.tabStrip.cardiovascularParent.label", - headerKey: "insights.tabStrip.cardiovascularParent.header", + heart: { + labelKey: "insights.tabStrip.heartParent.label", + headerKey: "insights.tabStrip.heartParent.header", }, hearing: { labelKey: "insights.tabStrip.hearingParent.label", @@ -517,7 +520,13 @@ function buildTabs( const emittedGroups = new Set(); for (const slug of orderedSlugs) { - const group = SUB_PAGE_GROUP[slug]; + // 2026-07-17 UX/IA audit (H2/M1) — a pinned slug always renders as + // its own flat pill, even if it carries a `SUB_PAGE_GROUP` entry (for + // manager/catalog purposes). It is also excluded below from its + // group's popover children so it never appears twice. + const group = PINNED_SUB_PAGE_SLUGS.has(slug) + ? undefined + : SUB_PAGE_GROUP[slug]; if (group) { if (emittedGroups.has(group)) continue; emittedGroups.add(group); @@ -529,7 +538,10 @@ function buildTabs( ) : SUB_PAGE_GROUP_ORDER[group]; const children = orderedChildren - .filter((childSlug) => visibleSet.has(childSlug)) + .filter( + (childSlug) => + visibleSet.has(childSlug) && !PINNED_SUB_PAGE_SLUGS.has(childSlug), + ) .map((childSlug) => ({ slug: childSlug, href: `${INSIGHTS_OVERVIEW_PATH}/${childSlug}`, diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx index 59fa7eb51..0e6e82d3d 100644 --- a/src/components/settings/insights-pill-order-section.tsx +++ b/src/components/settings/insights-pill-order-section.tsx @@ -73,7 +73,7 @@ const MANAGER_GROUP_HEADER_KEYS: Record = { body: "insights.tabStrip.bodyParent.label", activity: "insights.tabStrip.activityParent.label", sleep: "insights.editMode.groupSleep", - cardiovascular: "insights.tabStrip.cardiovascularParent.label", + heart: "insights.tabStrip.heartParent.label", hearing: "insights.tabStrip.hearingParent.label", environment: "insights.tabStrip.environmentParent.label", metabolic: "insights.tabStrip.metabolicParent.label", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index b2f92ded7..a23c4470f 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -66,11 +66,52 @@ export { type SettingsSectionSlug, }; +/** + * 2026-07-17 UX/IA audit (M6) — the sidebar's 19 entries render under one + * of these seven non-interactive group headers instead of as one flat + * list. Presentation only: no slug, route, or redirect changes. Order + * below is the render order of the groups themselves. + */ +export type SettingsSectionGroup = + | "account" + | "yourData" + | "display" + | "healthProfile" + | "ai" + | "connectivity" + | "system"; + +export const SETTINGS_SECTION_GROUP_ORDER: readonly SettingsSectionGroup[] = [ + "account", + "yourData", + "display", + "healthProfile", + "ai", + "connectivity", + "system", +]; + +/** i18n key for each group's header label, under `settings.shell.groups.*`. */ +export const SETTINGS_SECTION_GROUP_LABEL_KEYS: Record< + SettingsSectionGroup, + string +> = { + account: "settings.shell.groups.account", + yourData: "settings.shell.groups.yourData", + display: "settings.shell.groups.display", + healthProfile: "settings.shell.groups.healthProfile", + ai: "settings.shell.groups.ai", + connectivity: "settings.shell.groups.connectivity", + system: "settings.shell.groups.system", +}; + interface SettingsSection { slug: SettingsSectionSlug; /** i18n key under `settings.sections..title`. */ titleKey: string; icon: LucideIcon; + /** 2026-07-17 UX/IA audit (M6) — which sidebar group this entry renders under. */ + group: SettingsSectionGroup; /** * v1.18.0 (S5) — per-submodule entries are listed only when their module * is enabled. When set, the nav entry hides if the resolved @@ -83,104 +124,152 @@ interface SettingsSection { /** * Source of truth for the section list that the settings shell renders - * in its sidebar + mobile chip-strip. Order matches the in-app - * navigation order. Don't reorder without updating tests. + * in its sidebar + mobile chip-strip. * - * "About" sits at the end of the list. v1.4.33 IW7 had folded it into - * the sidebar user-card dropdown only, which left `/settings/about` an - * orphaned route — reachable by URL but discoverable nowhere in the - * settings navigation. It is now both a regular (last) shell entry and - * a user-card dropdown item ("Über HealthLog" / "About HealthLog"). + * 2026-07-17 UX/IA audit (M6) — the list used to render in a single flat + * order with no visual structure (19 entries deep, the AI cluster and the + * programmatic-access cluster interleaved, the health-profile pair split + * across the list). It is now clustered into the seven + * `SettingsSectionGroup`s (`SETTINGS_SECTION_GROUP_ORDER`), each entry + * carrying its `group`; the shell inserts a non-interactive header before + * the first VISIBLE entry of each group. This is a presentation-only + * reorder — no slug, route, or redirect changed, and `SETTINGS_SECTION_SLUGS` + * (`section-slugs.ts`, which also carries the page-only slugs and drives + * `generateStaticParams()`) is untouched. * - * v1.18.0 (S3) — `sources` is no longer a sidebar entry: source priority - * folded into Settings → Integrations as the "Sources" sub-tab. - * `thresholds` (Targets) keeps its own entry, served by the dynamic - * `[section]` route. + * "About" sits at the end of the list (System group). v1.4.33 IW7 had + * folded it into the sidebar user-card dropdown only, which left + * `/settings/about` an orphaned route — reachable by URL but discoverable + * nowhere in the settings navigation. It is now both a regular shell entry + * and a user-card dropdown item ("Über HealthLog" / "About HealthLog"). */ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ - { slug: "account", titleKey: "settings.sections.account.title", icon: User }, + // ── Account ── + { + slug: "account", + titleKey: "settings.sections.account.title", + icon: User, + group: "account", + }, // v1.23 — the account-security home: second factors, recovery codes, - // passkey management. Sits directly under Account. + // passkey management. { slug: "security", titleKey: "settings.sections.security.title", icon: ShieldCheck, + group: "account", }, - // v1.18.0 — the "Was du trackst" hub. Sits right below Account: the one - // place to enable/disable the secondary tracking domains. + // v1.23 — "Data & Privacy" assembles the export / deletion / retention / + // encryption / session / activity pieces into one coherent privacy pane. + { + slug: "privacy", + titleKey: "settings.sections.privacy.title", + icon: Lock, + group: "account", + }, + // ── Your data ── + // v1.18.0 — the "Was du trackst" hub: the front door for enabling / + // disabling the secondary tracking domains. { slug: "modules", titleKey: "settings.sections.modules.title", icon: Blocks, + group: "yourData", }, { slug: "integrations", titleKey: "settings.sections.integrations.title", icon: Link2, + group: "yourData", }, - // v1.25.3 — the standalone Kanäle (delivery channels) entry folded back - // into Notifications as a labelled in-page group; `/settings/channels` - // 301-redirects to `/settings/notifications#channels`. - // v1.18.1 (D4) — Quellen (source weighting) split out into its own entry: - // which connection wins when two report the same metric. + // v1.18.1 (D4) — Quellen (source weighting): which connection wins when + // two report the same metric. v1.25.3 — Kanäle (delivery channels) folded + // back into Notifications as an in-page group; + // `/settings/channels` 301-redirects to `/settings/notifications#channels`. { slug: "sources", titleKey: "settings.sections.sources.title", icon: Layers, + group: "yourData", }, - // v1.18.0 (S4) — "Benachrichtigungen" is now the single module-gated - // reminder-types home. The separate "Erinnerungen" hub (a link-only page) - // is gone; reminder TYPES live here, each row gated on its module. + // v1.18.0 (S5) — the full health record (PDF + FHIR R4 + zip package). + // v1.18.6.1 — NOT module-gated: the health record is a flagship export + // capability; gating its only entry-point on the `doctorReport` opt-out + // meant a stray toggle in the Modules hub made the whole section vanish + // with no in-context way back. The `doctorReport` module still governs + // the data layer server-side (403 `module.disabled` over Bearer). { - slug: "notifications", - titleKey: "settings.sections.notifications.title", - icon: Bell, + slug: "gesundheitsakte", + titleKey: "settings.sections.gesundheitsakte.title", + icon: FileHeart, + group: "yourData", }, + // v1.25.7 — clinician share links fold into the Gesundheitsakte section as + // a labelled "Sharing" group; `/settings/sharing` 301-redirects there. + { + slug: "export", + titleKey: "settings.sections.export.title", + icon: Download, + group: "yourData", + }, + // ── Display ── // v1.17.1 (F-2) — one nav entry replaces the four scattered Dashboard / - // Insights / Medications / Mood "arrange" entries. Those routes still - // resolve (deep links, page-header cogs, and the hub's own links all work); - // they are reached through this hub now, so "how my app looks" reads as one - // home. v1.25.3 — renamed to "Appearance"/"Darstellung" and widened into the - // index for every module's view/sort/order surface. The slug stays `layout` - // so every route + deep link is untouched. - // v1.25.7 — "Darstellung" (Appearance) absorbs the per-module view/sort/ - // order surfaces. The standalone Medikamente / Stimmung / Labor / - // Krankheit / Vorsorge nav entries are gone: each module's full settings - // v1.25.11 (#148) — "Darstellung" is a HUB that lists each module and links - // to its own subpage at `/settings/layout/`. The old per-module - // routes 301-redirect to the matching subpage (next.config.ts). + // Insights / Medications / Mood "arrange" entries; those routes still + // resolve, reached through this hub. v1.25.3 — renamed to "Appearance" / + // "Darstellung" and widened into the index for every module's + // view/sort/order surface (the slug stays `layout`). v1.25.11 (#148) — + // each module lives on its own subpage at `/settings/layout/`; + // the old per-module routes 301-redirect there. { slug: "layout", titleKey: "settings.sections.layout.title", icon: Palette, + group: "display", }, - // v1.25 (W-ENV) — Environmental context: home location, travel overrides, and - // the weather/daylight backfill. Module-gated on the opt-in `environment` - // module, so the entry only appears once the user turns it on. - { - slug: "environment", - titleKey: "settings.sections.environment.title", - icon: CloudSun, - moduleGate: "environment", - }, - // v1.25 (W-RECORDS) — Anamnese (medical history): the structured-record home - // for allergies + family history. NOT module-gated (like Vorsorge) — these - // are foundational health-profile records, always available. Sits between - // the illness journal and the preventive-care reminders. - { - slug: "anamnesis", - titleKey: "settings.sections.anamnesis.title", - icon: ClipboardList, - }, - // v1.25.7 — Vorsorge (preventive-care reminders) folded into "Darstellung" - // alongside the other tracking modules; `/settings/vorsorge` 301-redirects - // to `/settings/layout/vorsorge` (next.config.ts). + // v1.25.7 — Vorsorge (preventive-care reminders) folded into + // "Darstellung"; `/settings/vorsorge` 301-redirects to + // `/settings/layout/vorsorge`. { slug: "thresholds", titleKey: "settings.sections.thresholds.title", icon: SlidersHorizontal, + group: "display", + }, + // v1.18.0 (S4) — "Benachrichtigungen" is the single module-gated + // reminder-types home; the standalone "Erinnerungen" hub is gone. + { + slug: "notifications", + titleKey: "settings.sections.notifications.title", + icon: Bell, + group: "display", + }, + // ── Health profile ── + // v1.25 (W-RECORDS) — Anamnese (medical history): the structured-record + // home for allergies + family history. NOT module-gated (like Vorsorge) — + // these are foundational health-profile records, always available. + { + slug: "anamnesis", + titleKey: "settings.sections.anamnesis.title", + icon: ClipboardList, + group: "healthProfile", + }, + // v1.25 (W-ENV) — Environmental context: home location, travel overrides, + // and the weather/daylight backfill. Module-gated on the opt-in + // `environment` module, so the entry only appears once the user turns it on. + { + slug: "environment", + titleKey: "settings.sections.environment.title", + icon: CloudSun, + group: "healthProfile", + moduleGate: "environment", + }, + // ── AI ── + { + slug: "ai", + titleKey: "settings.sections.ai.title", + icon: Sparkles, + group: "ai", }, - { slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles }, // v1.18.0 (S5) — Coach: the Coach preference cards get their own entry, // shown only when the coach module is enabled. The AI entry above keeps // provider / model / BYOK configuration. @@ -188,54 +277,37 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ slug: "coach", titleKey: "settings.sections.coach.title", icon: Bot, + group: "ai", moduleGate: "coach", }, - { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound }, - // v1.22.0 — remote MCP connector. Not module-gated: the card carries its own - // enable toggle (mirroring `gesundheitsakte`), so the entry-point stays - // reachable even with the opt-in module off. - { slug: "mcp", titleKey: "settings.sections.mcp.title", icon: Plug }, - // v1.18.0 (S5) — the full health record (PDF + FHIR R4 + zip package) - // earns its own home, lifted out of Export & Import. - // - // v1.18.6.1 — the nav entry is NOT module-gated. The health record is a - // flagship export capability; gating its only entry-point on the - // `doctorReport` opt-out meant a stray toggle in the Modules hub made the - // whole section vanish with no in-context way back. The entry now always - // shows. The `doctorReport` module still governs the data layer: the - // server-side `/api/export/health-record` route remains the hard - // enforcement (403 `module.disabled` over Bearer when the account opts - // out), and the Modules hub keeps the toggle for anyone who wants the - // surface gone elsewhere — but the Settings entry-point stays reachable. + // ── Connectivity ── { - slug: "gesundheitsakte", - titleKey: "settings.sections.gesundheitsakte.title", - icon: FileHeart, + slug: "api", + titleKey: "settings.sections.api.title", + icon: KeyRound, + group: "connectivity", }, - // v1.25.7 — clinician share links fold into the Gesundheitsakte section as a - // labelled "Sharing" group: minting a time-boxed read-only link is a sharing - // face of the same data. `/settings/sharing` 301-redirects to - // `/settings/gesundheitsakte#sharing` (next.config.ts). The public - // `/c/[token]` view and the `/api/share-links` lifecycle are unchanged. + // v1.22.0 — remote MCP connector. Not module-gated: the card carries its + // own enable toggle (mirroring `gesundheitsakte`), so the entry-point + // stays reachable even with the opt-in module off. { - slug: "export", - titleKey: "settings.sections.export.title", - icon: Download, + slug: "mcp", + titleKey: "settings.sections.mcp.title", + icon: Plug, + group: "connectivity", }, + // ── System ── { slug: "advanced", titleKey: "settings.sections.advanced.title", icon: Settings2, - }, - { - slug: "privacy", - titleKey: "settings.sections.privacy.title", - icon: Lock, + group: "system", }, { slug: "about", titleKey: "settings.sections.about.title", icon: Info, + group: "system", }, ] as const; @@ -471,29 +543,41 @@ export function SettingsShell({ aria-label={t("settings.shell.sectionsNav")} className="no-scrollbar relative -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden" > -
    - {visibleSections.map((section) => { +
      + {visibleSections.map((section, index) => { const isActive = section.slug === activeSlug; const Icon = section.icon; + // 2026-07-17 UX/IA audit (M6) — a quiet vertical divider marks a + // group boundary in the horizontal strip (the desktop sidebar + // carries the full text header for the same grouping). + const startsNewGroup = + index > 0 && visibleSections[index - 1].group !== section.group; return ( -
    • - -
    • + + {startsNewGroup ? ( + + ) : null} +
    • + +
    • +
      ); })}
    @@ -537,25 +621,44 @@ export function SettingsShell({ className="no-scrollbar hidden max-h-[calc(100dvh-5.5rem)] overflow-y-auto md:sticky md:top-6 md:col-start-1 md:row-start-2 md:block md:self-start" >
      - {visibleSections.map((section) => { + {visibleSections.map((section, index) => { const isActive = section.slug === activeSlug; const Icon = section.icon; + // 2026-07-17 UX/IA audit (M6) — a non-interactive header + // precedes the first VISIBLE entry of each group so a + // module-gated entry never leaves an orphan header above an + // empty cluster. + const startsNewGroup = + index === 0 || + visibleSections[index - 1].group !== section.group; return ( -
    • - -
    • + + {startsNewGroup ? ( +
    • + {t(SETTINGS_SECTION_GROUP_LABEL_KEYS[section.group])} +
    • + ) : null} +
    • + +
    • +
      ); })}
    diff --git a/src/lib/insights/metric-catalog.ts b/src/lib/insights/metric-catalog.ts index a88c07ecc..d2ef75963 100644 --- a/src/lib/insights/metric-catalog.ts +++ b/src/lib/insights/metric-catalog.ts @@ -69,7 +69,7 @@ export const CATALOG_GROUP_HEADER_KEYS: Record = { body: "insights.tabStrip.bodyParent.header", activity: "insights.tabStrip.activityParent.header", sleep: "insights.editMode.groupSleep", - cardiovascular: "insights.tabStrip.cardiovascularParent.header", + heart: "insights.tabStrip.heartParent.header", hearing: "insights.tabStrip.hearingParent.header", environment: "insights.tabStrip.environmentParent.header", metabolic: "insights.tabStrip.metabolicParent.header", @@ -198,7 +198,7 @@ function buildMetricEntries(): CatalogEntry[] { */ const ECG_ENTRY: CatalogEntry = { id: "ecg", - group: "cardiovascular", + group: "heart", nameKey: "insights.ecg.sectionTitle", sourceKey: "metricCatalog.ecg.source", kind: "metric", @@ -217,7 +217,7 @@ const ECG_ENTRY: CatalogEntry = { */ const WATCHDOG_ENTRY: CatalogEntry = { id: "watchdog", - group: "cardiovascular", + group: "heart", nameKey: "metricCatalog.watchdog.title", sourceKey: "metricCatalog.watchdog.description", kind: "info", diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index bbe2bc391..7b2cb26ad 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -41,10 +41,16 @@ // so the five pills collapse behind one "Vitalwerte" / "Vitals" parent // pill with a popover sub-list. The `group: "vitals"` field below // drives that grouping; the canonical order inside the popover follows -// the strip order. The earlier inline pills (`pulse`, `blood-pressure`, -// `weight`, `bmi`, `sleep`, `mood`, `medications`, `workouts`) stay -// flat — they predate v1.4.32 and the maintainer's audit kept them as -// direct entries. +// the strip order. +// +// 2026-07-17 UX/IA audit (H2, M1, M2, M3) — the inline pills that used to +// stay flat "because they predate v1.4.32" are now flat by deliberate +// design or not at all: `blood-pressure`, `weight`, `sleep`, `medications` +// and `mood` are PINNED (see `PINNED_SUB_PAGE_SLUGS` below) because they +// are the account's highest-traffic pages, not because grouping them was +// never considered. `pulse`, `bmi` and `workouts` were flat by historical +// accident only — they now collapse into their correct group (`heart`, +// `body`, `activity`) like every sibling metric. const SUB_PAGE_METRIC = { // ── vitals ── "blood-pressure": ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"], @@ -156,27 +162,43 @@ export const SUB_PAGE_SLUGS = Object.keys(SUB_PAGE_METRIC) as SubPageSlug[]; * `insights.tabStrip.Parent.*` in every locale. */ // v1.7.0 — the new metric clusters each collapse behind a parent pill -// so the strip stays scannable as the sub-page count grows. The flat -// pills (pulse, blood-pressure, weight, bmi, sleep, mood, medications, -// workouts) stay direct entries as before. +// so the strip stays scannable as the sub-page count grows. Only the +// `PINNED_SUB_PAGE_SLUGS` (blood-pressure, weight, sleep, mood, +// medications) stay direct entries. export type SubPageGroup = | "vitals" | "body" | "activity" - | "cardiovascular" + | "heart" | "hearing" | "environment" | "metabolic"; +/** + * 2026-07-17 UX/IA audit (H2) — slugs pinned as flat, always-top-level + * pills regardless of any `SUB_PAGE_GROUP` entry they carry. This is the + * account's short list of highest-traffic pages (per the audit's + * "Pinned flat pills" proposal); everything else renders through its + * group. A pinned slug is excluded from its group's popover children in + * the tab strip (it already has its own pill) but still counts toward + * its `SUB_PAGE_MANAGER_GROUP` for the "Detailseiten verwalten" manager + * and the metric catalog. + */ +export const PINNED_SUB_PAGE_SLUGS: ReadonlySet = new Set([ + "blood-pressure", + "weight", + "sleep", + "medications", + "mood", +]); + export const SUB_PAGE_GROUP: Partial> = { // vitals - hrv: "vitals", - "resting-pulse": "vitals", oxygen: "vitals", "body-temperature": "vitals", - "active-energy": "vitals", "respiratory-rate": "vitals", // body composition (v1.7.0) + bmi: "body", "body-water": "body", "bone-mass": "body", "fat-free-mass": "body", @@ -186,6 +208,8 @@ export const SUB_PAGE_GROUP: Partial> = { "lean-body-mass": "body", // activity / mobility (v1.7.0) steps: "activity", + "active-energy": "activity", + workouts: "activity", "flights-climbed": "activity", "walking-distance": "activity", "walking-steadiness": "activity", @@ -194,9 +218,17 @@ export const SUB_PAGE_GROUP: Partial> = { "double-support-time": "activity", "step-length": "activity", "walking-speed": "activity", - // cardiovascular (v1.7.0) - "pulse-wave-velocity": "cardiovascular", - "vascular-age": "cardiovascular", + // heart (2026-07-17 UX/IA audit H2) — pulse, resting HR, HRV, cardio + // recovery and the Withings vascular markers now share one coherent + // "Heart" popover instead of pulse/resting-HR/HRV being scattered + // across Vitals and a "Cardiovascular" group that only ever held the + // three rarest markers. + pulse: "heart", + "resting-pulse": "heart", + hrv: "heart", + "pulse-wave-velocity": "heart", + "vascular-age": "heart", + "cardio-recovery": "heart", // hearing (v1.7.0) "environmental-audio": "hearing", "headphone-audio": "hearing", @@ -213,10 +245,6 @@ export const SUB_PAGE_GROUP: Partial> = { "six-minute-walk": "activity", "stair-ascent-speed": "activity", "stair-descent-speed": "activity", - // vitals cluster (breathing sits with respiratory rate) - "breathing-disturbances": "vitals", - // cardiovascular cluster - "cardio-recovery": "cardiovascular", // metabolic cluster (overnight wrist reading) "wrist-temperature": "metabolic", // ── v1.25 — physical / clinical signals ── @@ -242,9 +270,7 @@ export const SUB_PAGE_GROUP_ORDER: Record< activity: SUB_PAGE_SLUGS.filter( (slug) => SUB_PAGE_GROUP[slug] === "activity", ), - cardiovascular: SUB_PAGE_SLUGS.filter( - (slug) => SUB_PAGE_GROUP[slug] === "cardiovascular", - ), + heart: SUB_PAGE_SLUGS.filter((slug) => SUB_PAGE_GROUP[slug] === "heart"), hearing: SUB_PAGE_SLUGS.filter((slug) => SUB_PAGE_GROUP[slug] === "hearing"), environment: SUB_PAGE_SLUGS.filter( (slug) => SUB_PAGE_GROUP[slug] === "environment", @@ -258,24 +284,25 @@ export const SUB_PAGE_GROUP_ORDER: Record< * v1.15.14 W2 — manager grouping for the "Anpassen" → "Detailseiten * verwalten" surface. * - * The tab strip only assigns a `SUB_PAGE_GROUP` to the long-tail metrics - * that collapse behind a popover parent pill; the eight legacy flat pills - * (blood-pressure, pulse, weight, bmi, sleep, mood, medications, workouts) - * render as direct entries and carry NO `SUB_PAGE_GROUP`. The sub-page - * MANAGER, by contrast, lists EVERY slug grouped under a category header, - * so it needs a total slug → group mapping that covers the flat pills too - * and adds the three categories the tab strip never groups (`sleep`, - * `mood`, `events`). This map is that total assignment; it is a superset - * of `SUB_PAGE_GROUP` (every `SUB_PAGE_GROUP` entry agrees with it) plus a - * fallback for the flat slugs. Single source of truth for the manager — - * the tab-strip nav grouping stays `SUB_PAGE_GROUP` exactly as before. + * The tab strip only assigns a `SUB_PAGE_GROUP` to the metrics that + * collapse behind a popover parent pill; the pinned flat pills + * (blood-pressure, weight, sleep, mood, medications — + * `PINNED_SUB_PAGE_SLUGS`) render as direct entries and carry NO + * `SUB_PAGE_GROUP`. The sub-page MANAGER, by contrast, lists EVERY slug + * grouped under a category header, so it needs a total slug → group + * mapping that covers the pinned slugs too and adds the three categories + * the tab strip never groups (`sleep`, `mood`, `events`). This map is + * that total assignment; it is a superset of `SUB_PAGE_GROUP` (every + * `SUB_PAGE_GROUP` entry agrees with it) plus a fallback for the pinned + * slugs. Single source of truth for the manager — the tab-strip nav + * grouping stays `SUB_PAGE_GROUP` exactly as before. */ export type ManagerGroup = | "vitals" | "body" | "activity" | "sleep" - | "cardiovascular" + | "heart" | "hearing" | "environment" | "metabolic" @@ -285,7 +312,7 @@ export type ManagerGroup = /** * Render order of the manager's category sections — mirrors the * MeasurementCategory overlay the rest of insights follows - * (vitals → body → activity → sleep → cardiovascular → hearing → + * (vitals → body → activity → sleep → heart → hearing → * environment → metabolic → mood → events). */ export const MANAGER_GROUP_ORDER: readonly ManagerGroup[] = [ @@ -293,7 +320,7 @@ export const MANAGER_GROUP_ORDER: readonly ManagerGroup[] = [ "body", "activity", "sleep", - "cardiovascular", + "heart", "hearing", "environment", "metabolic", @@ -304,13 +331,15 @@ export const MANAGER_GROUP_ORDER: readonly ManagerGroup[] = [ /** Explicit group for the flat tab-strip slugs (those `SUB_PAGE_GROUP` omits). */ const FLAT_SLUG_MANAGER_GROUP: Partial> = { "blood-pressure": "vitals", - pulse: "vitals", weight: "body", - bmi: "body", - workouts: "activity", sleep: "sleep", mood: "mood", medications: "events", + // 2026-07-17 UX/IA audit (M3) — breathing disturbances stays a flat, + // ungrouped tab-strip pill (see `SUB_PAGE_GROUP`) but files under the + // Sleep section in the manager, not the foreign Vitals popover it used + // to disappear from whenever the sleep module was off. + "breathing-disturbances": "sleep", // v1.29 — nutrient intake filed with the metabolic cluster (alongside // blood glucose / skin / wrist temperature). nutrients: "metabolic",