refactor(insights, settings): unify the Heart group and add settings sidebar groups

The insights tab strip's "Cardiovascular" popover held only three rare
Withings markers while pulse, resting heart rate, HRV, and cardio
recovery sat scattered across a flat pill and the unrelated Vitals
group. Cardiovascular is now Heart and gathers all six; bmi and
workouts — flat only by historical accident — collapse into their
correct Body / Activity groups, and breathing disturbances moves out
of Vitals into the Sleep manager bucket instead of vanishing whenever
the sleep module is off. Blood pressure, weight, sleep, mood, and
medications stay pinned as flat top-level pills — now a documented
`PINNED_SUB_PAGE_SLUGS` set instead of an unexplained "predates
v1.4.32" carve-out. Active energy moves from Vitals to Activity to
match its own tab-strip section comment. All changes are grouping /
presentation data only — no slug, route, or redirect changed.

The Settings sidebar's 19 entries rendered as one flat list with no
visual structure. They now cluster under seven group headers (Account,
Your data, Display, Health profile, AI, Connectivity, System),
reordered to match; the desktop sidebar gets a non-interactive text
header per group, the mobile chip strip a quiet divider. Presentation
only — no route changed.

Reference: 2026-07-17 UX/IA audit, H2 + M1 + M2 + M3 + M6.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 01:23:37 +02:00
1 parent 4c436d7dfc
commit e7802701ba
13 files changed
+504 -233

No files matched your search

+13 -4
View File
@@ -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": {
+13 -4
View File
@@ -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": {
+13 -4
View File
@@ -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": {
+13 -4
View File
@@ -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": {
+13 -4
View File
@@ -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": {
+13 -4
View File
@@ -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": {
+1 -1
View File
@@ -78,7 +78,7 @@ const GROUP_ICONS: Record<ManagerGroup, LucideIcon> = {
body: Scale,
activity: Footprints,
sleep: Moon,
cardiovascular: HeartPulse,
heart: HeartPulse,
hearing: Ear,
environment: Sun,
metabolic: Droplet,
@@ -70,10 +70,11 @@ describe("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
hasMedication: false,
};
const html = render(<InsightsTabStrip availability={availability} />);
// 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("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
};
const html = render(<InsightsTabStrip availability={availability} />);
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("<InsightsTabStrip> — vitals group collapse (v1.4.34 IW-D)", () => {
};
const html = render(<InsightsTabStrip availability={availability} />);
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("<InsightsTabStrip> — 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(<InsightsTabStrip availability={fullAvailability} />);
// 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("<InsightsTabStrip> — 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(<InsightsTabStrip availability={heartAvailability} />);
const parentMatches = html.match(/>Heart</g);
expect(parentMatches).not.toBeNull();
expect(parentMatches!.length).toBe(1);
expect(html).toContain('data-group="heart"');
expect(html).not.toContain(">Pulse<");
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(<InsightsTabStrip availability={availability} />);
expect(html).not.toContain(">Heart<");
expect(html).not.toContain('data-group="heart"');
});
});
describe("<InsightsTabStrip> — 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(<InsightsTabStrip availability={availability} />);
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(<InsightsTabStrip availability={availability} />);
expect(html).not.toContain(">Workouts<");
expect(html).toContain('data-group="activity"');
});
});
@@ -255,8 +320,10 @@ describe("<InsightsTabStrip> — 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(
<InsightsTabStrip
@@ -265,11 +332,11 @@ describe("<InsightsTabStrip> — 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(
<InsightsTabStrip
@@ -277,7 +344,7 @@ describe("<InsightsTabStrip> — 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("<InsightsTabStrip> — 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(<InsightsTabStrip availability={dataAvailability} />);
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("<InsightsTabStrip> — 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("<InsightsTabStrip> — 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("<InsightsTabStrip> — 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(
<InsightsTabStrip
availability={moduleAvailability}
@@ -435,12 +505,14 @@ describe("<InsightsTabStrip> — 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("<InsightsTabStrip> — 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("<InsightsTabStrip> — 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<");
}
+21 -9
View File
@@ -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<SubPageGroup>();
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}`,
@@ -73,7 +73,7 @@ const MANAGER_GROUP_HEADER_KEYS: Record<ManagerGroup, string> = {
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",
+232 -129
View File
@@ -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.<slug>.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/<module>`. 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/<module>`;
// 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"
>
<ul className="flex min-w-max gap-2">
{visibleSections.map((section) => {
<ul className="flex min-w-max items-center gap-2">
{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 (
<li key={section.slug} className="snap-start">
<Link
href={`/settings/${section.slug}`}
aria-current={isActive ? "page" : undefined}
className={cn(
// v1.4.25 W8 — chip strip is the primary mobile-settings
// navigation surface. Pad to WCAG 2.5.5 44 px so the
// chips can be tapped without zoom on a Pixel-5.
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
isActive
? "border-primary/40 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{t(section.titleKey)}
</Link>
</li>
<React.Fragment key={section.slug}>
{startsNewGroup ? (
<li aria-hidden="true" className="shrink-0">
<span className="bg-border block h-6 w-px" />
</li>
) : null}
<li className="snap-start">
<Link
href={`/settings/${section.slug}`}
aria-current={isActive ? "page" : undefined}
className={cn(
// v1.4.25 W8 — chip strip is the primary mobile-settings
// navigation surface. Pad to WCAG 2.5.5 44 px so the
// chips can be tapped without zoom on a Pixel-5.
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
isActive
? "border-primary/40 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{t(section.titleKey)}
</Link>
</li>
</React.Fragment>
);
})}
</ul>
@@ -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"
>
<ul className="space-y-1">
{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 (
<li key={section.slug}>
<Link
href={`/settings/${section.slug}`}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{t(section.titleKey)}
</Link>
</li>
<React.Fragment key={section.slug}>
{startsNewGroup ? (
<li
className={cn(
"text-muted-foreground px-3 pb-1 text-xs font-medium tracking-wide uppercase",
index === 0 ? "" : "pt-4",
)}
>
{t(SETTINGS_SECTION_GROUP_LABEL_KEYS[section.group])}
</li>
) : null}
<li>
<Link
href={`/settings/${section.slug}`}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{t(section.titleKey)}
</Link>
</li>
</React.Fragment>
);
})}
</ul>
+3 -3
View File
@@ -69,7 +69,7 @@ export const CATALOG_GROUP_HEADER_KEYS: Record<ManagerGroup, string> = {
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",
+67 -38
View File
@@ -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.<group>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<SubPageSlug> = new Set([
"blood-pressure",
"weight",
"sleep",
"medications",
"mood",
]);
export const SUB_PAGE_GROUP: Partial<Record<SubPageSlug, SubPageGroup>> = {
// 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<Record<SubPageSlug, SubPageGroup>> = {
"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<Record<SubPageSlug, SubPageGroup>> = {
"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<Record<SubPageSlug, SubPageGroup>> = {
"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<Record<SubPageSlug, ManagerGroup>> = {
"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",