From 8a7a2bc2e84a65f32f7d6b6b45cfc4ba1feb5fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:03:09 +0200 Subject: [PATCH] Link a measurement type to its insights sub-page Clicking a type on the measurements list now drills into that metric's insights sub-page when one exists, falling back to the existing ?type= filtered list when it does not. A reverse type-to-slug map backs the navigation, preferring the dedicated single-metric page so an ambiguous type resolves correctly (PULSE to pulse, not blood-pressure; WEIGHT to weight, not bmi). No API change. --- messages/de.json | 1 + messages/en.json | 1 + .../measurements/measurement-list.tsx | 54 ++++++++++++++++--- .../__tests__/sub-page-metric-reverse.test.ts | 53 ++++++++++++++++++ src/lib/insights/sub-page-metric.ts | 46 ++++++++++++++++ 5 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 src/lib/insights/__tests__/sub-page-metric-reverse.test.ts diff --git a/messages/de.json b/messages/de.json index e3e68d06a..5493833e1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -511,6 +511,7 @@ "previousPage": "Vorherige Seite", "nextPage": "Nächste Seite", "dailyTotalCaption": "({count} Einzelwerte)", + "openMetricInsights": "{type}-Auswertung öffnen", "sleepNapCaption": "+{count} Nickerchen", "sleepAwakeningsCaption": "{count} Wachphasen", "expandDay": "Einzelwerte dieses Tages anzeigen", diff --git a/messages/en.json b/messages/en.json index 44106a387..a74a55a31 100644 --- a/messages/en.json +++ b/messages/en.json @@ -511,6 +511,7 @@ "previousPage": "Previous page", "nextPage": "Next page", "dailyTotalCaption": "({count} samples)", + "openMetricInsights": "Open {type} insights", "sleepNapCaption": "+{count} nap(s)", "sleepAwakeningsCaption": "{count} awakening(s)", "expandDay": "Show samples for this day", diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index b270a1fd4..aa6c3bf59 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -1,5 +1,6 @@ "use client"; +import { useRouter } from "next/navigation"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { Button } from "@/components/ui/button"; @@ -90,6 +91,10 @@ import { apiPost, apiPut, } from "@/lib/api/api-fetch"; +import { + INSIGHTS_OVERVIEW_PATH, + subPageSlugForType, +} from "@/lib/insights/sub-page-metric"; /** * v1.4.37 W7c — cumulative HK types whose list view collapses to one @@ -277,6 +282,20 @@ export function MeasurementList({ const fmt = useFormatters(); const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); + const router = useRouter(); + // v1.22 — clicking a row's type badge drills into that metric's Insights + // sub-page when one exists, else into the filtered measurements list. The + // reverse map prefers the dedicated single-metric slug (PULSE → `pulse`, + // WEIGHT → `weight`); types with no sub-page fall back to the `?type=` deep + // link the page already supports. + const navigateForType = (type: string) => { + const slug = subPageSlugForType(type); + router.push( + slug + ? `${INSIGHTS_OVERVIEW_PATH}/${slug}` + : `/measurements?type=${encodeURIComponent(type)}`, + ); + }; // v1.8.5 — when `lockedType` is set the list is pinned to that metric // and the type selector is hidden; the filter state seeds from it. // v1.18.7 (Wave E) — `initialType` (from a `?type=` deep link) seeds the @@ -922,12 +941,26 @@ export function MeasurementList({ - {TYPE_LABEL_KEYS[m.type] - ? t(TYPE_LABEL_KEYS[m.type]) - : m.type} + @@ -1111,11 +1144,18 @@ export function MeasurementList({ )} {Icon && ( -
navigateForType(m.type)} + aria-label={t("measurements.openMetricInsights", { + type: TYPE_LABEL_KEYS[m.type] + ? t(TYPE_LABEL_KEYS[m.type]) + : m.type, + })} + className={`focus-visible:ring-ring/50 flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`} > -
+ )}
{/* v1.4.27 MB7 / CF-76 — bump the metadata diff --git a/src/lib/insights/__tests__/sub-page-metric-reverse.test.ts b/src/lib/insights/__tests__/sub-page-metric-reverse.test.ts new file mode 100644 index 000000000..ffaa085f0 --- /dev/null +++ b/src/lib/insights/__tests__/sub-page-metric-reverse.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; + +import { + TYPE_TO_SUB_PAGE_SLUG, + subPageSlugForType, +} from "@/lib/insights/sub-page-metric"; + +/** + * v1.22 — the measurements list lets a user click a metric type to drill + * into its Insights sub-page. The reverse map underneath must resolve an + * ambiguous type to its dedicated single-metric page, never a multi-metric + * cluster page, and must stay silent for types with no card so the caller + * falls back to the filtered list. + */ +describe("type → insights sub-page slug", () => { + it("prefers the dedicated single-metric page over a multi-metric one", () => { + // PULSE lives on both `pulse` (=[PULSE]) and `blood-pressure` + // (=[SYS, DIA, PULSE]) — the single-metric page wins. + expect(subPageSlugForType("PULSE")).toBe("pulse"); + // WEIGHT lives on both `weight` (=[WEIGHT]) and the derived `bmi` + // (=[WEIGHT]) — the first single-metric page in slug order wins. + expect(subPageSlugForType("WEIGHT")).toBe("weight"); + }); + + it("falls back to the multi-metric page for its component types", () => { + expect(subPageSlugForType("BLOOD_PRESSURE_SYS")).toBe("blood-pressure"); + expect(subPageSlugForType("BLOOD_PRESSURE_DIA")).toBe("blood-pressure"); + // Both HRV flavours only appear on the `hrv` page. + expect(subPageSlugForType("HEART_RATE_VARIABILITY")).toBe("hrv"); + expect(subPageSlugForType("HRV_RMSSD")).toBe("hrv"); + }); + + it("maps a plain single-metric type to its page", () => { + expect(subPageSlugForType("ACTIVITY_STEPS")).toBe("steps"); + expect(subPageSlugForType("MOOD")).toBe("mood"); + expect(subPageSlugForType("BLOOD_GLUCOSE")).toBe("blood-glucose"); + }); + + it("returns undefined for a type with no sub-page", () => { + expect(subPageSlugForType("BODY_FAT")).toBeUndefined(); + expect(subPageSlugForType("RECOVERY_SCORE")).toBeUndefined(); + expect(subPageSlugForType("NOT_A_REAL_TYPE")).toBeUndefined(); + }); + + it("never resolves a type to an empty-metric (event-driven) page", () => { + // `workouts` and `medications` carry no measurement series, so no type + // should ever resolve to them. + for (const slug of Object.values(TYPE_TO_SUB_PAGE_SLUG)) { + expect(slug).not.toBe("workouts"); + expect(slug).not.toBe("medications"); + } + }); +}); diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index 8c6421aa9..73967b7da 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -321,6 +321,52 @@ export const SUB_PAGE_MANAGER_GROUP_SLUGS: Record< ]), ) as unknown as Record; +/** + * v1.22 — reverse lookup: a measurement type → the routed insights sub-page + * slug that focuses it, when one exists. + * + * Built from {@link SUB_PAGE_METRIC}, preferring the slug whose metric list + * is exactly `[type]` so an ambiguous type resolves to its dedicated + * single-metric page rather than a multi-metric cluster: `PULSE` → `pulse` + * (not `blood-pressure`), `WEIGHT` → `weight` (not the derived `bmi`). When + * two single-metric pages list the same type (`weight` + `bmi` both list + * `WEIGHT`) the first in `SUB_PAGE_SLUGS` order wins (`weight`). A type that + * only ever appears on a multi-metric page (the two blood-pressure + * components, the two HRV flavours) falls back to that page. Types with no + * sub-page at all (BODY_FAT, RECOVERY_SCORE, the `*_EVENT` series, most + * device scores) are absent — the caller falls back to the filtered + * measurements list. + */ +export const TYPE_TO_SUB_PAGE_SLUG: Readonly> = + (() => { + const map: Record = {}; + // Pass 1 — exact single-metric pages win (first occurrence in slug order). + for (const slug of SUB_PAGE_SLUGS) { + const metrics = SUB_PAGE_METRIC[slug]; + if (metrics.length !== 1) continue; + const type = metrics[0]; + if (!(type in map)) map[type] = slug; + } + // Pass 2 — multi-metric pages fill any type a single-metric page misses. + for (const slug of SUB_PAGE_SLUGS) { + const metrics = SUB_PAGE_METRIC[slug]; + if (metrics.length === 1) continue; + for (const type of metrics) { + if (!(type in map)) map[type] = slug; + } + } + return map; + })(); + +/** + * The insights sub-page slug for a measurement type, or `undefined` when no + * routed card focuses it. See {@link TYPE_TO_SUB_PAGE_SLUG} for the + * ambiguity-resolution rule. + */ +export function subPageSlugForType(type: string): SubPageSlug | undefined { + return TYPE_TO_SUB_PAGE_SLUG[type]; +} + /** * Mother-page route. Kept here as a named constant so call sites * never have to hard-code the string.