Merge pull request #300 from MBombeck/release/v1.15.14

v1.15.14 — manage every detail page, calmer mood charts
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-07 21:10:13 +02:00
commit 1156168c21
21 files changed
+737 -172

No files matched your search

+10
View File
@@ -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
+1 -1
View File
@@ -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.
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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é",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("<InsightsEditMode> — broadened sub-page manager (v1.15.14 W2)", () => {
const noneGated: ReadonlySet<InsightsSectionId> = new Set();
it("relabels the Vitals disclosure to 'Manage detail pages'", () => {
const html = render(
<InsightsEditMode
layout={DEFAULT_INSIGHTS_LAYOUT}
gatedOffSectionIds={noneGated}
onClose={() => {}}
/>,
);
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");
});
});
@@ -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("<InsightsTabStrip> — vitals group collapse (v1.4.34 IW-D)", () => {
expect(html).toContain(">Workouts<");
});
});
describe("<InsightsTabStrip> — 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(
<InsightsTabStrip
availability={dataAvailability}
visibleTileIds={visibleTileIds}
/>,
);
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(
<InsightsTabStrip
availability={dataAvailability}
visibleTileIds={visibleTileIds}
/>,
);
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(
<InsightsTabStrip
availability={dataAvailability}
visibleTileIds={visibleTileIds}
/>,
);
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(<InsightsTabStrip availability={dataAvailability} />);
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<string>(); // nothing visible
const html = render(
<InsightsTabStrip
availability={dataAvailability}
visibleTileIds={visibleTileIds}
/>,
);
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(
<InsightsTabStrip
availability={availability}
visibleTileIds={visibleTileIds}
/>,
);
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(
<InsightsTabStrip
availability={availability}
visibleTileIds={visibleTileIds}
/>,
);
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(
<InsightsTabStrip
availability={availability}
visibleTileIds={visibleTileIds}
/>,
);
// 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<");
});
});
+122 -94
View File
@@ -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<InsightsSectionId, string> = {
};
/**
* 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.<group>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<ManagerGroup, string> = {
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<string, InsightsTileConfig>(
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")}
</button>
{/* 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. */}
<p
data-slot="insights-edit-tiles-nav-hint"
className="text-muted-foreground mt-1 text-xs"
>
{t("insights.editMode.manageSubPagesHint")}
</p>
{tilesOpen && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleTileDragEnd}
>
<SortableContext
items={vitalsTileIds}
strategy={verticalListSortingStrategy}
>
{/* 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. */}
<div className="mt-2 max-h-[60vh] space-y-1.5 overflow-y-auto overscroll-contain">
{vitalsTiles.map((tile) => (
<SortableTileRow
key={tile.id}
id={tile.id}
title={t(tile.labelKey)}
visible={tile.visible}
disabled={busy}
labels={{
dragHandle: t(
"insights.editMode.dragHandle",
),
show: t("insights.editMode.show"),
hide: t("insights.editMode.hide"),
}}
onToggle={toggleTile}
/>
))}
// 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.
<div className="mt-2 max-h-[60vh] space-y-3 overflow-y-auto overscroll-contain">
{tilesByGroup.map(({ group, rows }) => (
<div key={group} data-slot="insights-edit-tile-group" data-group={group}>
<p className="text-muted-foreground px-1 py-1 text-[11px] font-semibold tracking-wide uppercase">
{t(MANAGER_GROUP_HEADER_KEYS[group])}
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) =>
handleGroupTileDragEnd(group, event)
}
>
<SortableContext
items={rows.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1.5">
{rows.map((tile) => (
<SortableTileRow
key={tile.id}
id={tile.id}
title={t(tile.labelKey)}
visible={tile.visible}
disabled={busy}
labels={{
dragHandle: t(
"insights.editMode.dragHandle",
),
show: t("insights.editMode.show"),
hide: t("insights.editMode.hide"),
}}
onToggle={toggleTile}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</SortableContext>
</DndContext>
))}
</div>
)}
</div>
)}
@@ -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 (
<div className="space-y-8">
<InsightsTabStrip
onRegenerate={advisorEnabled ? advisor.regenerate : undefined}
regenerating={advisor.isRegenerating}
availability={availability}
visibleTileIds={visibleTileIds}
tileOrder={tileOrder}
/>
{children}
{/* v1.12.6 — SINGLE SOURCE of the generic Insights disclaimer.
+70 -7
View File
@@ -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<string>;
/**
* 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<string, number>;
}
/**
@@ -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<string> | undefined,
tileOrder: ReadonlyMap<string, number> | 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<SubPageGroup>();
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
@@ -53,18 +53,33 @@ export function MoodDistributionChart({
});
return (
<div className="aspect-[3/2] min-h-[180px] w-full">
// 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.
<div className="h-[150px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 8, left: 0 }}>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
// v1.15.14 — theme-aware axis text. `--dracula-fg` (near-white) is
// not overridden in `:root.light` (Alucard white card) → white-on-
// white. `--muted-foreground` is the token every other in-app chart
// uses for axis ticks and is legible in both themes; dark mode is
// unchanged. The mood-hue bar Cells stay on `--dracula-*` — only the
// axis text legibility changes here.
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--dracula-comment)"
interval={0}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
// v1.15.14 — theme-aware axis text. `--dracula-fg` (near-white) is
// not overridden in `:root.light` (Alucard white card) → white-on-
// white. `--muted-foreground` is the token every other in-app chart
// uses for axis ticks and is legible in both themes; dark mode is
// unchanged. The mood-hue bar Cells stay on `--dracula-*` — only the
// axis text legibility changes here.
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--dracula-comment)"
width={28}
/>
@@ -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) => (
<Cell
@@ -43,9 +43,7 @@ const MoodDistributionChart = dynamic(
})),
{
ssr: false,
loading: () => (
<Skeleton className="aspect-[3/2] min-h-[180px] w-full rounded-md" />
),
loading: () => <Skeleton className="h-[150px] w-full rounded-md" />,
},
);
const MoodWeekdayChart = dynamic(
@@ -55,9 +53,7 @@ const MoodWeekdayChart = dynamic(
})),
{
ssr: false,
loading: () => (
<Skeleton className="aspect-[3/2] min-h-[160px] w-full rounded-md" />
),
loading: () => <Skeleton className="h-[150px] w-full rounded-md" />,
},
);
const MoodTimeOfDayChart = dynamic(
@@ -73,7 +73,11 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
return (
<div className="space-y-2">
<div className="aspect-[3/2] min-h-[160px] w-full">
{/* 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. */}
<div className="h-[150px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
@@ -81,18 +85,34 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
// v1.15.14 — theme-aware axis text (see mood-distribution-chart).
// `--dracula-fg` was white-on-white on the light-mode card; switch
// to the shared `--muted-foreground` axis-tick token. Bar mood
// hues stay `--dracula-*`; dark mode is unchanged.
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--dracula-comment)"
interval={0}
/>
<YAxis
domain={[1, 5]}
ticks={[1, 2, 3, 4, 5]}
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
// v1.15.14 — theme-aware axis text (see mood-distribution-chart).
// `--dracula-fg` was white-on-white on the light-mode card; switch
// to the shared `--muted-foreground` axis-tick token. Bar mood
// hues stay `--dracula-*`; dark mode is unchanged.
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--dracula-comment)"
width={24}
/>
<ReferenceLine y={3.5} stroke="var(--dracula-green)" strokeDasharray="3 3" />
{/* 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. */}
<ReferenceLine
y={3.5}
stroke="var(--dracula-comment)"
strokeDasharray="3 3"
strokeOpacity={0.6}
/>
<Tooltip
cursor={{ fill: "var(--secondary)", opacity: 0.4 }}
contentStyle={{
@@ -116,7 +136,11 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
];
}}
/>
<Bar dataKey="value" radius={[3, 3, 0, 0]}>
{/* 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. */}
<Bar dataKey="value" radius={[3, 3, 0, 0]} fillOpacity={0.55}>
{data.map((row) => (
<Cell
key={row.weekday}
+34
View File
@@ -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);
}
});
});
+26 -49
View File
@@ -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<InsightsTileId>([
"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,
})),
};
@@ -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");
});
});
+89
View File
@@ -226,6 +226,95 @@ export const SUB_PAGE_GROUP_ORDER: Record<SubPageGroup, readonly SubPageSlug[]>
),
};
/**
* 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<Record<SubPageSlug, ManagerGroup>> = {
"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<SubPageSlug, ManagerGroup> =
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<SubPageSlug, ManagerGroup>;
/**
* 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<ManagerGroup, readonly SubPageSlug[]>;
/**
* Mother-page route. Kept here as a named constant so call sites
* never have to hard-code the string.