mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
21 files changed
+737
-172
No files matched your search
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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<");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user