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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:03:09 +02:00
1 parent 7861c9b642
commit 8a7a2bc2e8
5 files changed
+148 -7

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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",
@@ -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({
</TableCell>
<TableCell>
<Badge
asChild
variant="secondary"
className={TYPE_COLORS[m.type] ?? ""}
className={`focus-visible:ring-ring/50 cursor-pointer transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`.trim()}
>
{TYPE_LABEL_KEYS[m.type]
? t(TYPE_LABEL_KEYS[m.type])
: m.type}
<button
type="button"
onClick={() => navigateForType(m.type)}
aria-label={t(
"measurements.openMetricInsights",
{
type: TYPE_LABEL_KEYS[m.type]
? t(TYPE_LABEL_KEYS[m.type])
: m.type,
},
)}
>
{TYPE_LABEL_KEYS[m.type]
? t(TYPE_LABEL_KEYS[m.type])
: m.type}
</button>
</Badge>
</TableCell>
<TableCell className="font-semibold tabular-nums">
@@ -1111,11 +1144,18 @@ export function MeasurementList({
</div>
)}
{Icon && (
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${TYPE_COLORS[m.type] ?? ""}`}
<button
type="button"
onClick={() => 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] ?? ""}`}
>
<Icon className="h-4 w-4" />
</div>
</button>
)}
<div className="min-w-0">
{/* v1.4.27 MB7 / CF-76 — bump the metadata
@@ -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");
}
});
});
+46
View File
@@ -321,6 +321,52 @@ export const SUB_PAGE_MANAGER_GROUP_SLUGS: Record<
]),
) as unknown as Record<ManagerGroup, readonly SubPageSlug[]>;
/**
* 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<Record<string, SubPageSlug>> =
(() => {
const map: Record<string, SubPageSlug> = {};
// 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.