mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
0f1b002108
commit
e7e123be01
8 files changed
+195
-94
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,58 @@ export function InsightsEditMode({
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("insights.editMode.manageTiles")}
|
||||
{t("insights.editMode.manageSubPages")}
|
||||
</button>
|
||||
{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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user