From e7e123be016c03a2c504aaa3b3ddc02b468d7523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 20:49:15 +0200 Subject: [PATCH] feat(insights): manage every sub-page from the customize panel The Anpassen tile manager only listed the ~14 vitals overview-grid tiles, so the rest of the routed sub-pages had no toggle anywhere. Now that the tab strip reads the same per-tile visibility, the manager becomes the one place to show, hide, and reorder every sub-page. Broaden the Kacheln-verwalten disclosure to the full sub-page universe, grouped by category (vitals, body, activity, sleep, cardiovascular, hearing, environment, metabolic, mood, events) the same way the strip groups them. Each group is its own drag context so a reorder never crosses a group boundary; rows reuse the strip's labels so a manager row reads like the nav pill it governs. The single tiles layout this writes now drives the overview vitals grid, the tab-strip pills, and this manager. Relabel the disclosure to Detailseiten verwalten / Manage detail pages across the six locales. --- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/pl.json | 4 + .../insights-edit-mode-manager.test.tsx | 58 +++++ .../insights/insights-edit-mode.tsx | 207 ++++++++++-------- 8 files changed, 195 insertions(+), 94 deletions(-) create mode 100644 src/components/insights/__tests__/insights-edit-mode-manager.test.tsx diff --git a/messages/de.json b/messages/de.json index 6c5c6e4b1..291fb25a5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1613,6 +1613,10 @@ "show": "Einblenden", "hide": "Ausblenden", "manageTiles": "Kacheln verwalten", + "manageSubPages": "Detailseiten verwalten", + "groupSleep": "Schlaf", + "groupMood": "Stimmung", + "groupEvents": "Ereignisse", "gatedHint": "Erscheint, sobald Daten oder die Funktion verfügbar sind", "allHiddenHint": "Alle Bereiche sind ausgeblendet. Blende mindestens einen ein, um deine Übersicht zu sehen.", "saveSuccess": "Übersicht gespeichert", diff --git a/messages/en.json b/messages/en.json index 547a0a04e..0de52041d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1615,6 +1615,10 @@ "show": "Show", "hide": "Hide", "manageTiles": "Manage tiles", + "manageSubPages": "Manage detail pages", + "groupSleep": "Sleep", + "groupMood": "Mood", + "groupEvents": "Events", "gatedHint": "Appears once data or the feature is available", "allHiddenHint": "Every section is hidden. Show at least one to see your overview.", "saveSuccess": "Overview layout saved", diff --git a/messages/es.json b/messages/es.json index 21c074df3..f35579dd7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1615,6 +1615,10 @@ "show": "Mostrar", "hide": "Ocultar", "manageTiles": "Gestionar tarjetas", + "manageSubPages": "Gestionar páginas de detalle", + "groupSleep": "Sueño", + "groupMood": "Estado de ánimo", + "groupEvents": "Eventos", "gatedHint": "Aparece cuando haya datos o la función esté disponible", "allHiddenHint": "Todas las secciones están ocultas. Muestra al menos una para ver tu resumen.", "saveSuccess": "Resumen guardado", diff --git a/messages/fr.json b/messages/fr.json index f6c054532..ab529b6d2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1615,6 +1615,10 @@ "show": "Afficher", "hide": "Masquer", "manageTiles": "Gérer les tuiles", + "manageSubPages": "Gérer les pages de détail", + "groupSleep": "Sommeil", + "groupMood": "Humeur", + "groupEvents": "Événements", "gatedHint": "Apparaît dès que des données ou la fonction sont disponibles", "allHiddenHint": "Toutes les sections sont masquées. Affiches-en au moins une pour voir ton aperçu.", "saveSuccess": "Aperçu enregistré", diff --git a/messages/it.json b/messages/it.json index e3b55fe43..b8a08ffd5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1615,6 +1615,10 @@ "show": "Mostra", "hide": "Nascondi", "manageTiles": "Gestisci riquadri", + "manageSubPages": "Gestisci pagine di dettaglio", + "groupSleep": "Sonno", + "groupMood": "Umore", + "groupEvents": "Eventi", "gatedHint": "Compare quando i dati o la funzione sono disponibili", "allHiddenHint": "Tutte le sezioni sono nascoste. Mostrane almeno una per vedere la panoramica.", "saveSuccess": "Panoramica salvata", diff --git a/messages/pl.json b/messages/pl.json index afae4ef72..0cfce2803 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1615,6 +1615,10 @@ "show": "Pokaż", "hide": "Ukryj", "manageTiles": "Zarządzaj kafelkami", + "manageSubPages": "Zarządzaj stronami szczegółów", + "groupSleep": "Sen", + "groupMood": "Nastrój", + "groupEvents": "Zdarzenia", "gatedHint": "Pojawi się, gdy dane lub funkcja będą dostępne", "allHiddenHint": "Wszystkie sekcje są ukryte. Pokaż co najmniej jedną, aby zobaczyć przegląd.", "saveSuccess": "Przegląd zapisany", diff --git a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx new file mode 100644 index 000000000..1c389a778 --- /dev/null +++ b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { InsightsEditMode } from "../insights-edit-mode"; +import { + DEFAULT_INSIGHTS_LAYOUT, + type InsightsSectionId, +} from "@/lib/insights-layout"; +import { + MANAGER_GROUP_ORDER, + SUB_PAGE_MANAGER_GROUP_SLUGS, +} from "@/lib/insights/sub-page-metric"; + +function render(node: React.ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +describe(" — broadened sub-page manager (v1.15.14 W2)", () => { + const noneGated: ReadonlySet = new Set(); + + it("relabels the Vitals disclosure to 'Manage detail pages'", () => { + const html = render( + {}} + />, + ); + expect(html).toContain('data-slot="insights-edit-tiles-disclosure"'); + expect(html).toContain("Manage detail pages"); + }); + + it("groups every routed sub-page slug under exactly one manager group", () => { + // The disclosure body is collapsed in SSR (no effects fire), so this + // asserts the data the manager renders FROM rather than the open DOM: + // every group in the manager order carries at least one slug, and the + // three tab-strip-flat categories (sleep / mood / events) are present. + expect(MANAGER_GROUP_ORDER).toContain("mood"); + expect(MANAGER_GROUP_ORDER).toContain("events"); + expect(MANAGER_GROUP_ORDER).toContain("sleep"); + // A non-vitals slug (`steps`) lands in the activity group so it is + // toggle-able from the manager — the round-trip the spec calls out. + expect(SUB_PAGE_MANAGER_GROUP_SLUGS.activity).toContain("steps"); + }); +}); diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index aaac26f46..4078ebedf 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -43,6 +43,12 @@ import { type InsightsSectionId, type InsightsTileConfig, } from "@/lib/insights-layout"; +import { + MANAGER_GROUP_ORDER, + SUB_PAGE_MANAGER_GROUP_SLUGS, + type ManagerGroup, +} from "@/lib/insights/sub-page-metric"; +import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip"; /** * v1.15.11 W3 — inline "Anpassen" edit mode for the customizable Insights @@ -78,34 +84,25 @@ const SECTION_TITLE_KEYS: Record = { }; /** - * The per-metric tile ids the Vitals section renders on the overview grid, - * paired with their localized measurement label key. This is the subset of the - * full tile-id universe that the Vitals dashboard actually surfaces (the four - * derived re-frames + the per-vital baseline tiles + the mobility sub-grid); - * the other layout tile ids drive the routed sub-page strip, not this grid, so - * they stay out of the inline tile editor. Order here is the default grid order - * (see `vitals-dashboard.tsx`); the user's saved `order` overrides it. + * v1.15.14 W2 — group-header label key per manager group. Reuses the + * tab-strip parent-pill labels (`insights.tabStrip.Parent.label`) + * so a section header in the manager reads exactly like the nav pill it + * governs; the three groups the tab strip never collapses (sleep / mood / + * events) get their own `insights.editMode.group*` keys. ONE source of + * labels keeps the customize surface and the nav in lockstep. */ -const VITALS_TILE_LABEL_KEYS: { id: string; labelKey: string }[] = [ - { id: "cardio-fitness", labelKey: "measurements.typeVo2Max" }, - { id: "vascular-age", labelKey: "measurements.typeVascularAge" }, - { id: "hrv", labelKey: "measurements.typeHeartRateVariability" }, - // v1.15.11 QA M1 — the Vitals grid renders a default-visible Weight tile; - // without this entry the inline editor could not hide/reorder it like every - // other grid tile. - { id: "weight", labelKey: "measurements.typeWeight" }, - { id: "bmi", labelKey: "measurements.typeBodyMassIndex" }, - { id: "resting-pulse", labelKey: "measurements.typeRestingHeartRate" }, - { id: "respiratory-rate", labelKey: "measurements.typeRespiratoryRate" }, - { id: "oxygen", labelKey: "measurements.typeOxygenSaturation" }, - { id: "body-temperature", labelKey: "measurements.typeBodyTemperature" }, - { id: "blood-glucose", labelKey: "measurements.typeBloodGlucose" }, - { id: "six-minute-walk", labelKey: "measurements.typeSixMinuteWalkDistance" }, - { id: "stair-ascent-speed", labelKey: "measurements.typeStairAscentSpeed" }, - { id: "stair-descent-speed", labelKey: "measurements.typeStairDescentSpeed" }, - { id: "wrist-temperature", labelKey: "measurements.typeWristTemperature" }, -]; -const VITALS_TILE_IDS = new Set(VITALS_TILE_LABEL_KEYS.map((t) => t.id)); +const MANAGER_GROUP_HEADER_KEYS: Record = { + vitals: "insights.tabStrip.vitalsParent.label", + body: "insights.tabStrip.bodyParent.label", + activity: "insights.tabStrip.activityParent.label", + sleep: "insights.editMode.groupSleep", + cardiovascular: "insights.tabStrip.cardiovascularParent.label", + hearing: "insights.tabStrip.hearingParent.label", + environment: "insights.tabStrip.environmentParent.label", + metabolic: "insights.tabStrip.metabolicParent.label", + mood: "insights.editMode.groupMood", + events: "insights.editMode.groupEvents", +}; interface InsightsEditModeProps { /** Resolved layout currently in effect (server copy, defaults while loading). */ @@ -211,22 +208,33 @@ export function InsightsEditMode({ ); const sectionIds = sections.map((s) => s.id); - const vitalsTiles = useMemo(() => { + // v1.15.14 W2 — the manager now lists EVERY sub-page slug grouped by + // category, not just the Vitals overview grid subset. Each group renders + // its slugs sorted by the draft's saved `order`; a slug the layout does + // not enumerate falls to the end. The `overview` tile is layout-only (the + // mother page, not a sub-page) so it never appears here. Labels reuse the + // tab-strip `SUB_PAGE_TABS[slug].labelKey` so the manager row reads like + // the nav pill it governs. + const tilesByGroup = useMemo(() => { const byId = new Map( draft.tiles.map((tt) => [tt.id, tt]), ); - return VITALS_TILE_LABEL_KEYS.map(({ id, labelKey }) => { - const cfg = byId.get(id); - return { - id, - labelKey, - visible: cfg?.visible ?? false, - order: cfg?.order ?? Number.MAX_SAFE_INTEGER, - }; - }).sort((a, b) => a.order - b.order); + return MANAGER_GROUP_ORDER.map((group) => { + const rows = SUB_PAGE_MANAGER_GROUP_SLUGS[group] + .map((slug) => { + const cfg = byId.get(slug); + return { + id: slug as string, + labelKey: SUB_PAGE_TABS[slug].labelKey, + visible: cfg?.visible ?? false, + order: cfg?.order ?? Number.MAX_SAFE_INTEGER, + }; + }) + .sort((a, b) => a.order - b.order); + return { group, rows }; + }).filter((g) => g.rows.length > 0); // Re-derive on any tile change. }, [draft.tiles]); - const vitalsTileIds = vitalsTiles.map((tt) => tt.id); function toggleSection(id: InsightsSectionId, visible: boolean) { setDraft((d) => ({ @@ -254,36 +262,35 @@ export function InsightsEditMode({ } /** - * Reorder the Vitals tiles WITHIN the draft's full tile list. The drag - * operates only over the Vitals subset, but the persisted `tiles` array - * carries every tile id (the routed sub-page strip reads it too), so we - * splice the reordered subset back into the full array, re-densifying the - * order only across the Vitals slots while leaving the non-Vitals tiles' - * relative order intact. + * v1.15.14 W2 — reorder the tiles WITHIN one manager group. Each group + * renders its own `SortableContext`, so a drag never crosses a group + * boundary; the group is identified from the dragged slug. We reorder the + * group's subset through the tested pure helper, then splice it back into + * the full `tiles` array (which also drives the tab strip + overview grid), + * re-densifying order only across that group's slots while every other + * tile keeps its relative order. */ - function handleTileDragEnd(event: DragEndEvent) { + function handleGroupTileDragEnd(group: ManagerGroup, event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id) return; - const subset = vitalsTiles.map((tt) => ({ + const groupRows = tilesByGroup.find((g) => g.group === group)?.rows ?? []; + const groupMemberIds = new Set(groupRows.map((r) => r.id)); + const subset = groupRows.map((tt) => ({ id: tt.id, order: tt.order, visible: tt.visible, })); - // Reorder ONLY the Vitals subset through the tested pure helper — a single - // total-order sort, never the mixed-key comparator that used to break - // transitivity (QA M2). + // Reorder ONLY this group's subset through the tested pure helper — a + // single total-order sort, never a mixed-key comparator (QA M2). const reordered = reorderById(subset, String(active.id), String(over.id)); - const reorderedVitalsIds = reordered.map((r) => r.id); + const reorderedIds = reordered.map((r) => r.id); setDraft((d) => ({ ...d, - // Substitute the Vitals slots in their new relative order while leaving - // the sub-page strip (non-Vitals tiles) untouched. Pure, total-order - // helper — no mixed-key comparator (QA M2). - tiles: rebuildTilesWithReorderedVitals( - d.tiles, - reorderedVitalsIds, - (id) => VITALS_TILE_IDS.has(id), + // Substitute this group's slots in their new relative order while leaving + // every other tile untouched. Pure, total-order helper (QA M2). + tiles: rebuildTilesWithReorderedVitals(d.tiles, reorderedIds, (id) => + groupMemberIds.has(id), ), })); } @@ -391,46 +398,58 @@ export function InsightsEditMode({ )} aria-hidden="true" /> - {t("insights.editMode.manageTiles")} + {t("insights.editMode.manageSubPages")} {tilesOpen && ( - - - {/* v1.15.11 QA M2-design — cap the disclosed tile - list height and let it scroll inside its own - container so dragging a tile on a phone doesn't - fight the page scroll. `overscroll-contain` keeps - the scroll local; the container is full-width and - only clips on the y-axis, so the drag ghost - (which translates within the row) is not cut. */} -
- {vitalsTiles.map((tile) => ( - - ))} + // v1.15.14 W2 — the manager now lists EVERY sub-page + // slug grouped by category, the same `tiles` layout the + // tab strip + overview Vitals grid read. Each group is + // its OWN `DndContext` + `SortableContext` so a drag + // never crosses a group boundary (and never cross-fires + // into the section drag). The whole list scrolls inside + // one capped container; `overscroll-contain` keeps the + // scroll local on touch. +
+ {tilesByGroup.map(({ group, rows }) => ( +
+

+ {t(MANAGER_GROUP_HEADER_KEYS[group])} +

+ + handleGroupTileDragEnd(group, event) + } + > + r.id)} + strategy={verticalListSortingStrategy} + > +
+ {rows.map((tile) => ( + + ))} +
+
+
- - + ))} +
)}
)}