From 7a8b6933f2031b18b4c956b5a1828bb401204f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 20:39:40 +0200 Subject: [PATCH 1/5] feat(insights): make the mood distribution and weekday charts more compact and calmer Both charts derived their height off the full card width via aspect-[3/2], which ballooned them on a wide viewport and let them dominate the mood subpage. Bound each to a fixed 150px band so they read as tidy sibling cards, and align the deferred-load skeletons to the same band. Soften the saturated full-bleed mood hues to a muted tint (fillOpacity 0.55) so the bars sit calmly over the card while the per-level hue still distinguishes the bands. On the weekday chart, drop the bright-green 3.5 target guide to a quiet comment-grey dashed line. The shared mood-level tokens used by the heatmap and legend are untouched. --- .../insights/mood/mood-distribution-chart.tsx | 12 +++++++++- .../insights/mood/mood-insights-sections.tsx | 8 ++----- .../insights/mood/mood-weekday-chart.tsx | 22 ++++++++++++++++--- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/components/insights/mood/mood-distribution-chart.tsx b/src/components/insights/mood/mood-distribution-chart.tsx index 7e2f39915..e9cdd5160 100644 --- a/src/components/insights/mood/mood-distribution-chart.tsx +++ b/src/components/insights/mood/mood-distribution-chart.tsx @@ -53,7 +53,10 @@ 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. +
{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..ab0e1777f 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) => ( Date: Sun, 7 Jun 2026 20:49:07 +0200 Subject: [PATCH 2/5] feat(insights): gate the tab strip on the saved layout The top insights navigation was driven purely by data availability: a sub-page pill appeared whenever its metric had readings in the 90-day window, with no connection to the per-tile visibility the customize panel writes. Hiding a sub-page in Anpassen therefore never removed its nav pill, and the two surfaces drifted apart. Thread the resolved insights layout into the tab strip via the layout shell. A pill now shows iff its metric has data AND its slug is visible in the saved layout; data availability stays the floor, so a layout-visible metric with no readings still gets no pill. Pills and group children order by the saved layout order, falling back to the natural slug order. While the layout query is in flight the strip keeps the data-only gate, so first paint is unchanged and a slow or failed layout fetch never suppresses a pill the user has data for. Add a total slug to manager-group mapping in sub-page-metric so every routed sub-page resolves to a category, a superset of the tab-strip nav grouping that also covers the flat pills and the sleep / mood / events categories the strip never collapses. --- .../__tests__/insights-tab-strip.test.tsx | 117 ++++++++++++++++++ .../insights/insights-layout-shell.tsx | 26 ++++ .../insights/insights-tab-strip.tsx | 77 ++++++++++-- .../__tests__/sub-page-manager-group.test.ts | 49 ++++++++ src/lib/insights/sub-page-metric.ts | 89 +++++++++++++ 5 files changed, 351 insertions(+), 7 deletions(-) create mode 100644 src/lib/insights/__tests__/sub-page-manager-group.test.ts diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index 8323e2595..1c8967edd 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -223,3 +223,120 @@ 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<"); + }); +}); 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/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. 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 3/5] 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) => ( + + ))} +
+
+
- - + ))} +
)}
)} From 6164a0c5c21cbeae27f6a7a362ba3a95f03cbc34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 21:01:26 +0200 Subject: [PATCH 4/5] fix(insights): keep every data-having pill in the default nav and reconcile review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default insights layout shipped a curated visible subset (v1.15.11), authored for the overview Vitals/Mobility grid. Once the tab strip began gating its nav pills on the same layout, that 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 flash-then-vanish on first paint. The grid renders a fixed mapped subset regardless of tile visibility, so defaulting every tile visible does not bloat it; what it restores is the nav's everything-with-data default. The user hides what they don't want. Also: - mood distribution + weekday charts: switch axis tick fill from the raw --dracula-fg (white-on-white on the light-mode card) to the shared --muted-foreground axis-text token; dark mode unchanged. - drop the orphaned insights.editMode.manageTiles key from all six locales (replaced by manageSubPages) and add a manageSubPagesHint noting that hiding a detail page also removes its top-nav pill. - pin the all-visible default with a layout test and a tab-strip default-nav regression test. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- .../__tests__/insights-tab-strip.test.tsx | 48 ++++++++++++ .../insights/insights-edit-mode.tsx | 9 +++ .../insights/mood/mood-distribution-chart.tsx | 16 +++- .../insights/mood/mood-weekday-chart.tsx | 12 ++- src/lib/__tests__/insights-layout.test.ts | 34 +++++++++ src/lib/insights-layout.ts | 75 +++++++------------ 12 files changed, 147 insertions(+), 59 deletions(-) diff --git a/messages/de.json b/messages/de.json index 291fb25a5..c09d62ccd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1612,8 +1612,8 @@ "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", diff --git a/messages/en.json b/messages/en.json index 0de52041d..a8d9cf9a4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1614,8 +1614,8 @@ "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", diff --git a/messages/es.json b/messages/es.json index f35579dd7..d11008a7e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1614,8 +1614,8 @@ "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", diff --git a/messages/fr.json b/messages/fr.json index ab529b6d2..ab8aaf052 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1614,8 +1614,8 @@ "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", diff --git a/messages/it.json b/messages/it.json index b8a08ffd5..1df6a9855 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1614,8 +1614,8 @@ "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", diff --git a/messages/pl.json b/messages/pl.json index 0cfce2803..bd5133174 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1614,8 +1614,8 @@ "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", diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index 1c8967edd..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( @@ -339,4 +340,51 @@ describe(" — saved-layout visibility gate (v1.15.14 W2)", () 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 4078ebedf..6fe2599ec 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -400,6 +400,15 @@ export function InsightsEditMode({ /> {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.14 W2 — the manager now lists EVERY sub-page // slug grouped by category, the same `tiles` layout the diff --git a/src/components/insights/mood/mood-distribution-chart.tsx b/src/components/insights/mood/mood-distribution-chart.tsx index e9cdd5160..302c784a5 100644 --- a/src/components/insights/mood/mood-distribution-chart.tsx +++ b/src/components/insights/mood/mood-distribution-chart.tsx @@ -61,13 +61,25 @@ export function MoodDistributionChart({ diff --git a/src/components/insights/mood/mood-weekday-chart.tsx b/src/components/insights/mood/mood-weekday-chart.tsx index ab0e1777f..cebd23e81 100644 --- a/src/components/insights/mood/mood-weekday-chart.tsx +++ b/src/components/insights/mood/mood-weekday-chart.tsx @@ -85,14 +85,22 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) { > diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts index 12689687f..8813c96e3 100644 --- a/src/lib/__tests__/insights-layout.test.ts +++ b/src/lib/__tests__/insights-layout.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect } from "vitest"; import { INSIGHTS_SECTION_IDS, + INSIGHTS_TILE_IDS, DEFAULT_INSIGHTS_LAYOUT, resolveInsightsLayout, serializeInsightsLayout, @@ -298,4 +299,37 @@ describe("resolveTileLayout — W2c per-tile decision", () => { 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, })), }; From 1d4b470efab9dd9fa55ed59de0a13afb385756ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 21:02:51 +0200 Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=20v1.15.14=20=E2=80=94=20ma?= =?UTF-8?q?nage=20every=20detail=20page,=20calmer=20mood=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) 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/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",