diff --git a/CHANGELOG.md b/CHANGELOG.md index c6292d151..1324d74a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [1.15.14] — 2026-06-07 — manage every detail page, calmer mood charts + +### Added + +- **Manage every detail page from the overview.** "Anpassen" → "Detailseiten verwalten" now lists every insights sub-page, grouped by category, so you can reorder and hide any of them — not just the vitals tiles. The top navigation follows your choices: hide a page and its pill leaves the nav too. + +### Changed + +- **Calmer, more compact mood charts.** The mood distribution and weekday-average charts on the mood page are smaller and use softer colour, so the page reads more easily. Their axis labels are now legible in the light theme too. + ## [1.15.13] — 2026-06-07 — search, filter, and bulk delete ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 232fd2dae..2cfa3060d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.15.13 + version: 1.15.14 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 6c5c6e4b1..c09d62ccd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1612,7 +1612,11 @@ "dragHandle": "Zum Sortieren ziehen", "show": "Einblenden", "hide": "Ausblenden", - "manageTiles": "Kacheln verwalten", + "manageSubPages": "Detailseiten verwalten", + "manageSubPagesHint": "Ausgeblendete Seiten verschwinden auch aus der oberen Navigation.", + "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..a8d9cf9a4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1614,7 +1614,11 @@ "dragHandle": "Drag to reorder", "show": "Show", "hide": "Hide", - "manageTiles": "Manage tiles", + "manageSubPages": "Manage detail pages", + "manageSubPagesHint": "Hidden pages are also removed from the top navigation.", + "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..d11008a7e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1614,7 +1614,11 @@ "dragHandle": "Arrastra para reordenar", "show": "Mostrar", "hide": "Ocultar", - "manageTiles": "Gestionar tarjetas", + "manageSubPages": "Gestionar páginas de detalle", + "manageSubPagesHint": "Las páginas ocultas también se quitan de la navegación superior.", + "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..ab8aaf052 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1614,7 +1614,11 @@ "dragHandle": "Fais glisser pour réorganiser", "show": "Afficher", "hide": "Masquer", - "manageTiles": "Gérer les tuiles", + "manageSubPages": "Gérer les pages de détail", + "manageSubPagesHint": "Les pages masquées disparaissent aussi de la navigation supérieure.", + "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..1df6a9855 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1614,7 +1614,11 @@ "dragHandle": "Trascina per riordinare", "show": "Mostra", "hide": "Nascondi", - "manageTiles": "Gestisci riquadri", + "manageSubPages": "Gestisci pagine di dettaglio", + "manageSubPagesHint": "Le pagine nascoste vengono rimosse anche dalla navigazione in alto.", + "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..bd5133174 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1614,7 +1614,11 @@ "dragHandle": "Przeciągnij, aby zmienić kolejność", "show": "Pokaż", "hide": "Ukryj", - "manageTiles": "Zarządzaj kafelkami", + "manageSubPages": "Zarządzaj stronami szczegółów", + "manageSubPagesHint": "Ukryte strony znikają również z górnej nawigacji.", + "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/package.json b/package.json index 2a1960a16..46f294faa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.15.13", + "version": "1.15.14", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", 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/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index 8323e2595..3646d0efc 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -16,6 +16,7 @@ vi.mock("sonner", () => ({ toast: { success: vi.fn() } })); import { InsightsTabStrip } from "../insights-tab-strip"; import type { InsightInputs } from "@/lib/insights/metric-availability"; import type { DataSummary } from "@/lib/analytics/trends"; +import { DEFAULT_INSIGHTS_LAYOUT } from "@/lib/insights-layout"; function render(node: React.ReactNode, locale: "en" | "de" = "en") { return renderToStaticMarkup( @@ -223,3 +224,167 @@ describe(" — vitals group collapse (v1.4.34 IW-D)", () => { expect(html).toContain(">Workouts<"); }); }); + +describe(" — saved-layout visibility gate (v1.15.14 W2)", () => { + const dataAvailability: InsightInputs = { + summaries: { + PULSE: fakeSummary(5), + WEIGHT: fakeSummary(3), + }, + hasMood: false, + hasMedication: false, + }; + + 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. + const visibleTileIds = new Set(["overview", "weight", "bmi"]); + const html = render( + , + ); + expect(html).toContain(">Weight<"); + expect(html).toContain(">BMI<"); + expect(html).not.toContain(">Pulse<"); + }); + + it("shows a pill when its slug is layout-visible AND has data", () => { + const visibleTileIds = new Set([ + "overview", + "pulse", + "weight", + "bmi", + ]); + const html = render( + , + ); + expect(html).toContain(">Pulse<"); + expect(html).toContain(">Weight<"); + }); + + it("keeps data-availability as the FLOOR — a layout-visible metric with zero data stays hidden", () => { + // `blood-pressure` is layout-visible but has no data ⇒ still no pill. + const visibleTileIds = new Set([ + "overview", + "blood-pressure", + "weight", + "bmi", + ]); + const html = render( + , + ); + expect(html).not.toContain(">Blood Pressure<"); + expect(html).toContain(">Weight<"); + }); + + 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(">Weight<"); + expect(html).toContain(">BMI<"); + }); + + it("always shows the Overview pill regardless of layout visibility", () => { + const visibleTileIds = new Set(); // nothing visible + const html = render( + , + ); + expect(html).toContain(">Overview<"); + expect(html).not.toContain(">Pulse<"); + expect(html).not.toContain(">Weight<"); + }); + + it("hides a group parent pill when every child is layout-hidden", () => { + // Activity group: ACTIVITY_STEPS has data, but `steps` is not visible. + const availability: InsightInputs = { + summaries: { ACTIVITY_STEPS: fakeSummary(4200) }, + hasMood: false, + hasMedication: false, + }; + const visibleTileIds = new Set(["overview"]); // steps hidden + const html = render( + , + ); + expect(html).not.toContain('data-group="activity"'); + expect(html).not.toContain(">Activity<"); + }); + + it("shows a group parent pill when at least one child is visible + has data", () => { + const availability: InsightInputs = { + summaries: { ACTIVITY_STEPS: fakeSummary(4200) }, + hasMood: false, + hasMedication: false, + }; + const visibleTileIds = new Set(["overview", "steps"]); + const html = render( + , + ); + expect(html).toContain('data-group="activity"'); + expect(html).toContain(">Activity<"); + }); + + // v1.15.14 — regression guard: the DEFAULT layout's visible set must equal + // the data-only nav set. The v1.15.11 curated default dropped ~20 pills once + // the strip began gating on the layout; making the default all-visible + // restores everything-with-data. Derive `visibleTileIds` straight from + // `DEFAULT_INSIGHTS_LAYOUT` (what a fresh / never-customized account + // resolves to) and assert the long-tail pills + group parents still show + // when their data is present. + it("default layout shows every data-having pill (nav-pill regression guard)", () => { + const visibleTileIds = new Set( + DEFAULT_INSIGHTS_LAYOUT.tiles + .filter((t) => t.visible) + .map((t) => t.id), + ); + const availability: InsightInputs = { + summaries: { + SLEEP_DURATION: fakeSummary(20), + ACTIVITY_STEPS: fakeSummary(4200), + ACTIVE_ENERGY_BURNED: fakeSummary(30), + FAT_MASS: fakeSummary(12), + MUSCLE_MASS: fakeSummary(12), + AUDIO_EXPOSURE_ENV: fakeSummary(40), + TIME_IN_DAYLIGHT: fakeSummary(15), + }, + hasMood: false, + hasMedication: false, + }; + const html = render( + , + ); + // Flat pill that previously regressed out of the default nav. + expect(html).toContain(">Sleep<"); + // Group parents that the curated default suppressed: Activity (steps / + // active-energy), Body (fat-mass / muscle-mass), Hearing (audio), + // Environment (daylight). + expect(html).toContain('data-group="activity"'); + expect(html).toContain(">Activity<"); + expect(html).toContain('data-group="body"'); + expect(html).toContain(">Body<"); + expect(html).toContain('data-group="hearing"'); + expect(html).toContain(">Hearing<"); + expect(html).toContain('data-group="environment"'); + expect(html).toContain(">Environment<"); + }); +}); diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index aaac26f46..6fe2599ec 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,67 @@ export function InsightsEditMode({ )} aria-hidden="true" /> - {t("insights.editMode.manageTiles")} + {t("insights.editMode.manageSubPages")} + {/* v1.15.14 — flag that hiding a detail page also drops + its top-nav pill, so a user isn't surprised a metric + vanished from the navigation. */} +

+ {t("insights.editMode.manageSubPagesHint")} +

{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) => ( + + ))} +
+
+
- - + ))} +
)}
)} diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 2f6d31349..942b33a13 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -11,6 +11,7 @@ import { useWorkouts } from "@/hooks/use-workouts"; import { InsightsTabStrip } from "@/components/insights/insights-tab-strip"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; import { queryKeys } from "@/lib/query-keys"; import type { InsightInputs } from "@/lib/insights/metric-availability"; @@ -112,12 +113,37 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { return { summaries, hasMood, hasMedication, hasWorkouts }; }, [isAuthenticated, summaries, hasMood, hasMedication, hasWorkouts]); + // v1.15.14 W2 — couple the tab strip to the saved insights layout. + // A sub-page pill now shows iff its metric has data AND its slug is + // layout-`visible`; the strip orders pills by the saved `order`. We + // pass the derived visibility set + order map only once the GET has + // SETTLED (`isSuccess`) — while the layout query is in flight the + // strip falls back to the data-only gate (props `undefined`) so the + // first paint is unchanged and a slow/failed layout fetch never + // suppresses a pill the user has data for. The query shares + // `queryKeys.insightsLayout()` with the overview + edit mode, so a + // "Fertig" save repaints the strip in lockstep. + const { layout: insightsLayout, isSuccess: layoutSettled } = + useInsightsLayoutQuery(isAuthenticated); + const visibleTileIds = useMemo(() => { + if (!layoutSettled) return undefined; + return new Set( + insightsLayout.tiles.filter((tile) => tile.visible).map((tile) => tile.id), + ); + }, [layoutSettled, insightsLayout]); + const tileOrder = useMemo(() => { + if (!layoutSettled) return undefined; + return new Map(insightsLayout.tiles.map((tile) => [tile.id, tile.order])); + }, [layoutSettled, insightsLayout]); + return (
{children} {/* v1.12.6 — SINGLE SOURCE of the generic Insights disclaimer. diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index b92503cff..91488705a 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -73,6 +73,22 @@ export interface InsightsTabStripProps { * stay backward-compatible. */ availability?: InsightInputs; + /** + * v1.15.14 W2 — the set of sub-page slugs the user's saved insights + * layout marks `visible`. A pill shows iff its metric has data AND its + * slug is in this set, so hiding a sub-page in "Anpassen" removes its + * nav pill. `undefined` (layout not yet loaded) falls back to the + * data-only gate so first paint is unchanged. Data availability stays + * the FLOOR — a layout-visible metric with zero data never gets a pill. + */ + visibleTileIds?: ReadonlySet; + /** + * v1.15.14 W2 — slug → saved-layout `order`. When present the strip + * orders flat pills, group parent pills (by first member), and group + * children by this order; absent, it falls back to the natural + * `SUB_PAGE_SLUGS` / `SUB_PAGE_GROUP_ORDER` order. + */ + tileOrder?: ReadonlyMap; } /** @@ -111,7 +127,7 @@ type TabEntry = // ORDER is owned by `SUB_PAGE_SLUGS` (which iterates the keys of // `SUB_PAGE_METRIC`) so adding a new metric is one row in // `sub-page-metric.ts` and the strip picks it up automatically. -const SUB_PAGE_TABS: Record< +export const SUB_PAGE_TABS: Record< SubPageSlug, { labelKey: string; metric: InsightMetric } > = { @@ -321,7 +337,11 @@ const SUB_PAGE_GROUP_META: Record< }, }; -function buildTabs(availability: InsightInputs | undefined): TabEntry[] { +function buildTabs( + availability: InsightInputs | undefined, + visibleTileIds: ReadonlySet | undefined, + tileOrder: ReadonlyMap | undefined, +): TabEntry[] { // v1.4.36 W4d — strict gating contract: a sub-page only surfaces a // pill when the availability inputs CONFIRM the metric has at // least one observation in the 90-day window. The legacy @@ -333,13 +353,39 @@ function buildTabs(availability: InsightInputs | undefined): TabEntry[] { // The pills appear progressively as `summaries[METRIC].count` // flips from 0 to ≥1 — exactly the auto-light-up behaviour // described in `metric-availability.ts` line 26. + // + // v1.15.14 W2 — the saved insights layout is now a SECOND gate on top + // of data availability. A pill shows iff its metric has data AND its + // slug is layout-`visible`. Data availability stays the FLOOR (a + // layout-visible metric with zero data still gets no pill). When the + // layout has not loaded yet (`visibleTileIds === undefined`) we fall + // back to the data-only gate so first paint matches the pre-W2 + // behaviour — the layout query then narrows the set on resolve. The + // group child `order` follows the layout (via `orderedSlugs` below), + // falling back to `SUB_PAGE_GROUP_ORDER` when the layout is absent. const visibleSlugs = availability - ? SUB_PAGE_SLUGS.filter((slug) => - hasMetricData(SUB_PAGE_TABS[slug].metric, availability), + ? SUB_PAGE_SLUGS.filter( + (slug) => + hasMetricData(SUB_PAGE_TABS[slug].metric, availability) && + (visibleTileIds === undefined || visibleTileIds.has(slug)), ) : []; const visibleSet = new Set(visibleSlugs); + + // v1.15.14 W2 — order the strip by the saved layout when present. The + // top-level iteration order decides where each flat pill and each group + // parent pill land; a group parent appears at the position of its + // first (lowest-order) visible member. Falling back to the natural + // `SUB_PAGE_SLUGS` order keeps the pre-W2 layout-absent behaviour. + const orderedSlugs = tileOrder + ? [...visibleSlugs].sort( + (a, b) => + (tileOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - + (tileOrder.get(b) ?? Number.MAX_SAFE_INTEGER), + ) + : visibleSlugs; + const entries: TabEntry[] = [ { kind: "link", @@ -353,12 +399,19 @@ function buildTabs(availability: InsightInputs | undefined): TabEntry[] { // same strip position (preserving categorical order). const emittedGroups = new Set(); - for (const slug of visibleSlugs) { + for (const slug of orderedSlugs) { const group = SUB_PAGE_GROUP[slug]; if (group) { if (emittedGroups.has(group)) continue; emittedGroups.add(group); - const children = SUB_PAGE_GROUP_ORDER[group] + const orderedChildren = tileOrder + ? [...SUB_PAGE_GROUP_ORDER[group]].sort( + (a, b) => + (tileOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - + (tileOrder.get(b) ?? Number.MAX_SAFE_INTEGER), + ) + : SUB_PAGE_GROUP_ORDER[group]; + const children = orderedChildren .filter((childSlug) => visibleSet.has(childSlug)) .map((childSlug) => ({ slug: childSlug, @@ -388,6 +441,8 @@ function InsightsTabStripImpl({ onRegenerate, regenerating = false, availability, + visibleTileIds, + tileOrder, }: InsightsTabStripProps) { const { t } = useTranslations(); const pathname = usePathname(); @@ -398,7 +453,15 @@ function InsightsTabStripImpl({ // reconciliation short-circuits when neither pathname nor // availability changed. Per // `.planning/research/v15-insights-blocking-bug.md` fix 2. - const tabs = useMemo(() => buildTabs(availability), [availability]); + // + // v1.15.14 W2 — the saved-layout visibility set + order map join the + // deps so the strip re-derives when the user toggles a sub-page in + // "Anpassen". The shell memoises both props so an unchanged layout + // doesn't churn the array. + const tabs = useMemo( + () => buildTabs(availability, visibleTileIds, tileOrder), + [availability, visibleTileIds, tileOrder], + ); // Fire success toast on the falling edge of `regenerating`. Same // rising-edge ref guard as the W3 implementation so the toast fires diff --git a/src/components/insights/mood/mood-distribution-chart.tsx b/src/components/insights/mood/mood-distribution-chart.tsx index 7e2f39915..302c784a5 100644 --- a/src/components/insights/mood/mood-distribution-chart.tsx +++ b/src/components/insights/mood/mood-distribution-chart.tsx @@ -53,18 +53,33 @@ export function MoodDistributionChart({ }); return ( -
+ // v1.15.14 — bounded compact height (was `aspect-[3/2] min-h-[180px]`, + // which ballooned the card on a wide viewport). A fixed band keeps this a + // tidy card rather than a dominant block, and matches the weekday sibling. +
@@ -93,6 +108,13 @@ export function MoodDistributionChart({ // than a missing slot. The coloured bar paints over the track // for non-empty levels. background={{ fill: "var(--secondary)", opacity: 0.35, radius: 3 }} + // v1.15.14 — the saturated full-bleed mood hues read as + // "erschlagend"; soften every bar to a muted tint of its level + // colour. The hue (= mood-level semantics) still distinguishes the + // bands, but the fill sits calmly over the card rather than + // shouting. Scoped to this chart only — the shared `--dracula-*` + // mood tokens (heatmap, legend) are untouched. + fillOpacity={0.55} > {data.map((row) => ( ( - - ), + loading: () => , }, ); const MoodWeekdayChart = dynamic( @@ -55,9 +53,7 @@ const MoodWeekdayChart = dynamic( })), { ssr: false, - loading: () => ( - - ), + loading: () => , }, ); const MoodTimeOfDayChart = dynamic( diff --git a/src/components/insights/mood/mood-weekday-chart.tsx b/src/components/insights/mood/mood-weekday-chart.tsx index 3842a64cc..cebd23e81 100644 --- a/src/components/insights/mood/mood-weekday-chart.tsx +++ b/src/components/insights/mood/mood-weekday-chart.tsx @@ -73,7 +73,11 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) { return (
-
+ {/* v1.15.14 — bounded compact height (was `aspect-[3/2] min-h-[160px]`, + which derived its height off the full card width and ballooned on a + wide viewport). A fixed band keeps it a tidy card matching the + distribution sibling. */} +
- + {/* v1.15.14 — calmer target guide: a muted comment-grey dashed + line instead of the bright green, so it reads as a quiet + reference rather than competing with the bars. */} + - + {/* v1.15.14 — soften the saturated mood hues to a muted tint + (the level semantics still distinguish the bars; the fill no + longer shouts). Scoped here — the shared `--dracula-*` mood + tokens used by the heatmap/legend stay untouched. */} + {data.map((row) => ( { expect(resolveTileLayout(DEFAULT_INSIGHTS_LAYOUT, id).visible).toBe(true); } }); + + // v1.15.14 — the default is ALL-VISIBLE. The v1.15.11 curated subset + // dropped ~20 nav pills once the tab strip began gating on the layout + // (sleep, steps, active-energy, walking-*, audio-*, daylight, the body- + // composition tiles, …). The grid renders a fixed mapped subset + // regardless of `tiles.visible`, so all-visible does NOT bloat it; what + // it restores is the nav's everything-with-data default. Pin it. + it("defaults EVERY tile visible (nav-pill regression guard)", () => { + expect(DEFAULT_INSIGHTS_LAYOUT.tiles.length).toBe(INSIGHTS_TILE_IDS.length); + for (const tile of DEFAULT_INSIGHTS_LAYOUT.tiles) { + expect(tile.visible).toBe(true); + } + // Spot-check the long-tail slugs that previously regressed out of nav. + for (const id of [ + "sleep", + "steps", + "active-energy", + "walking-distance", + "walking-speed", + "flights-climbed", + "environmental-audio", + "headphone-audio", + "daylight", + "breathing-disturbances", + "skin-temperature", + "body-water", + "fat-mass", + "muscle-mass", + "lean-body-mass", + ]) { + expect(resolveTileLayout(DEFAULT_INSIGHTS_LAYOUT, id).visible).toBe(true); + } + }); }); diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index 3a8eb7bc6..501136496 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -140,55 +140,30 @@ const INSIGHTS_LAYOUT_VERSION = 2; /** * Default layout mirrors the routed tab-strip order — vitals first, * then body composition, activity, sleep, cardiovascular, mood, then - * events. Core surfaces are default-visible; the long-tail HealthKit - * pills (oxygen saturation, body temperature, active energy, sleep, - * resting HR, HRV) stay default-invisible and the user opts in once - * the matching data starts to flow. Adding a new tile lands as an - * additional `{ id, visible: false, order: N }` row here and the - * resolver auto-merges it onto existing users' saved layouts. + * events. Every tile ships default-VISIBLE; the user hides what they + * don't want. Adding a new tile lands as an additional default-visible + * row here and the resolver auto-merges it onto existing users' saved + * layouts. + * + * v1.15.14 — the default is now ALL-VISIBLE. The v1.15.11 design shipped + * a curated subset (`DEFAULT_VISIBLE_TILE_IDS`) authored for the overview + * Vitals/Mobility GRID, which renders only a FIXED mapped set of tiles + * (the four derived re-frames + `SECTION_VITALS` + `SECTION_MOBILITY` — + * see `vitals-dashboard.tsx`). But once the tab strip began gating its + * nav pills on the same layout (v1.15.14 W2), that curated subset dropped + * ~20 nav pills for a fresh account with data in Sleep, Steps, + * Active-Energy, body composition, Walking/mobility, Audio, Daylight, + * etc. — pills that show today — and caused a "pills flash in then + * vanish" once the layout GET settled. + * + * The grid renders a fixed mapped subset regardless of `tiles.visible` + * (the long-tail slugs never appear in the grid), so defaulting EVERY + * tile visible does NOT bloat the grid — it stays its curated, data-gated + * set. What it restores is the tab strip's everything-with-data default: + * a fresh account sees every pill it has data for, and the user hides the + * ones they don't want. Data availability stays the floor everywhere — a + * visible tile with no readings still renders nothing. */ -// Core surfaces that ship default-visible for a fresh account. Every -// other tile (the long-tail HealthKit + body-composition + mobility + -// audio pills) stays default-invisible and the user opts in once the -// matching data starts to flow. Keeping this as an explicit set means -// the default-visibility decision is auditable in one place while the -// tile-id universe itself derives from `SUB_PAGE_SLUGS`. -const DEFAULT_VISIBLE_TILE_IDS = new Set([ - "overview", - "blood-pressure", - "pulse", - "weight", - "bmi", - "workouts", - "mood", - "medications", - // v1.15.11 W2c — the Vitals + Mobility grid on the overview surfaces every - // one of these tiles today, gated only on data availability. Now that the - // grid honours this layout's tile visibility, they must ship default-visible - // or a fresh account (no saved layout) would lose the tiles it shows today — - // a regression. They stay data-gated on top of visibility, so a tile with no - // readings still renders nothing exactly as before; widening the default set - // only ensures the layout never *suppresses* a tile the user already sees. - // The ids map to the grid's tile concepts: - // cardio-fitness → FitnessAge (VO₂max re-frame); vascular-age → - // VascularAge; hrv → HRV balance; resting-pulse / respiratory-rate / - // oxygen / body-temperature / blood-glucose → the per-vital baseline - // tiles; six-minute-walk / stair-ascent-speed / stair-descent-speed / - // wrist-temperature → the Mobility sub-grid. - "cardio-fitness", - "vascular-age", - "hrv", - "resting-pulse", - "respiratory-rate", - "oxygen", - "body-temperature", - "blood-glucose", - "six-minute-walk", - "stair-ascent-speed", - "stair-descent-speed", - "wrist-temperature", -]); - export const DEFAULT_INSIGHTS_LAYOUT: InsightsLayout = { version: INSIGHTS_LAYOUT_VERSION, // Every section ships default-visible; feature/data gates still apply @@ -203,9 +178,11 @@ export const DEFAULT_INSIGHTS_LAYOUT: InsightsLayout = { // `SUB_PAGE_SLUGS` order, which itself tracks the MeasurementCategory // overlay: vitals → body → activity → sleep → cardiovascular → mood → // events). The dense 0-based `order` mirrors the routed tab strip. + // Every tile defaults VISIBLE — see the module note above for why the + // v1.15.11 curated subset was a nav-pill regression. tiles: INSIGHTS_TILE_IDS.map((id, order) => ({ id, - visible: DEFAULT_VISIBLE_TILE_IDS.has(id), + visible: true, order, })), }; diff --git a/src/lib/insights/__tests__/sub-page-manager-group.test.ts b/src/lib/insights/__tests__/sub-page-manager-group.test.ts new file mode 100644 index 000000000..82c339234 --- /dev/null +++ b/src/lib/insights/__tests__/sub-page-manager-group.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; + +import { + MANAGER_GROUP_ORDER, + SUB_PAGE_MANAGER_GROUP, + SUB_PAGE_MANAGER_GROUP_SLUGS, + SUB_PAGE_SLUGS, + SUB_PAGE_GROUP, +} from "@/lib/insights/sub-page-metric"; + +/** + * v1.15.14 W2 — the "Anpassen" sub-page manager lists EVERY routed + * sub-page slug grouped by category. These guards pin the total grouping + * so a future slug can never silently drop out of the manager. + */ +describe("sub-page manager grouping", () => { + it("assigns every slug to exactly one manager group", () => { + for (const slug of SUB_PAGE_SLUGS) { + const group = SUB_PAGE_MANAGER_GROUP[slug]; + expect(group, `slug ${slug} has no manager group`).toBeDefined(); + expect(MANAGER_GROUP_ORDER).toContain(group); + } + }); + + it("the grouped-slugs map partitions the full slug universe", () => { + const flattened = MANAGER_GROUP_ORDER.flatMap( + (group) => SUB_PAGE_MANAGER_GROUP_SLUGS[group], + ); + // No slug missing. + expect(new Set(flattened)).toEqual(new Set(SUB_PAGE_SLUGS)); + // No slug counted twice. + expect(flattened.length).toBe(SUB_PAGE_SLUGS.length); + }); + + it("is a superset of the tab-strip nav grouping (SUB_PAGE_GROUP agrees)", () => { + for (const slug of SUB_PAGE_SLUGS) { + const navGroup = SUB_PAGE_GROUP[slug]; + if (navGroup) { + expect(SUB_PAGE_MANAGER_GROUP[slug]).toBe(navGroup); + } + } + }); + + it("covers the three categories the tab strip never collapses", () => { + expect(SUB_PAGE_MANAGER_GROUP_SLUGS.sleep.length).toBeGreaterThan(0); + expect(SUB_PAGE_MANAGER_GROUP_SLUGS.mood).toContain("mood"); + expect(SUB_PAGE_MANAGER_GROUP_SLUGS.events).toContain("medications"); + }); +}); diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index 0659d6759..103034569 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -226,6 +226,95 @@ 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. + */ +export type ManagerGroup = + | "vitals" + | "body" + | "activity" + | "sleep" + | "cardiovascular" + | "hearing" + | "environment" + | "metabolic" + | "mood" + | "events"; + +/** + * Render order of the manager's category sections — mirrors the + * MeasurementCategory overlay the rest of insights follows + * (vitals → body → activity → sleep → cardiovascular → hearing → + * environment → metabolic → mood → events). + */ +export const MANAGER_GROUP_ORDER: readonly ManagerGroup[] = [ + "vitals", + "body", + "activity", + "sleep", + "cardiovascular", + "hearing", + "environment", + "metabolic", + "mood", + "events", +]; + +/** 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", +}; + +/** + * Total slug → manager-group assignment. A slug grouped by the tab strip + * keeps that group; a flat slug falls back to {@link FLAT_SLUG_MANAGER_GROUP}. + * Every slug resolves (asserted by the manager-coverage test). + */ +export const SUB_PAGE_MANAGER_GROUP: Record = + Object.fromEntries( + SUB_PAGE_SLUGS.map((slug) => [ + slug, + (SUB_PAGE_GROUP[slug] as ManagerGroup | undefined) ?? + FLAT_SLUG_MANAGER_GROUP[slug] ?? + "vitals", + ]), + ) as unknown as Record; + +/** + * Slugs per manager group, in `SUB_PAGE_SLUGS` order. The manager renders + * one collapsible section per group following {@link MANAGER_GROUP_ORDER}, + * each listing these slugs. + */ +export const SUB_PAGE_MANAGER_GROUP_SLUGS: Record< + ManagerGroup, + readonly SubPageSlug[] +> = Object.fromEntries( + MANAGER_GROUP_ORDER.map((group) => [ + group, + SUB_PAGE_SLUGS.filter((slug) => SUB_PAGE_MANAGER_GROUP[slug] === group), + ]), +) as unknown as Record; + /** * Mother-page route. Kept here as a named constant so call sites * never have to hard-code the string.