feat(insights): stat strip, range band, all-readings subpage, diversity nudge

Add the data-value features to the insights category pages on top of the
density pass:

- Stat strip: lead each metric page with min / max / median / mean,
  read from the already-fetched analytics summaries slice (no new
  network). Median is now computed in both summary producers — exact in
  the live path, a windowed PERCENTILE_CONT in the rollup-tier slim
  slice. The thin HealthKit pages mount it too for parity; no AI
  assessment is added there.
- Range band: shade an Apple-Health-style min-max area around the daily
  mean line where the data supports it (non-cumulative metrics). The
  daily-aggregate read now emits per-day min / max from the rollup
  buckets; the band rides the existing target-range toggle.
- All readings: a dedicated per-metric subpage at /insights/values/<type>
  reusing the measurement list pinned to one type (pagination + inline
  edit/delete). A clear entry button mounts at the foot of each
  category page.
- Diversity nudge: a gentle hint when a metric's readings cluster on one
  weekday or time of day, computed from a bounded, cached timestamp
  window — non-blocking, self-gating.

All new reads stay cache-friendly and off the navigation path.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 20:26:26 +02:00
1 parent cf649f72ce
commit 6ce09cc76d
32 files changed
+1063 -37

No files matched your search

+79 -3
View File
@@ -7,6 +7,7 @@ import {
ResponsiveContainer,
ComposedChart,
Line,
Area,
XAxis,
YAxis,
CartesianGrid,
@@ -193,12 +194,20 @@ interface ChartDataPoint {
date: string;
timestamp: number;
pointIndex?: number;
[key: string]: string | number | undefined;
// v1.8.5 — `${type}__range` keys carry a `[min, max]` tuple for the
// range-band <Area>; every other key stays a scalar.
[key: string]: string | number | undefined | [number, number];
}
interface MeasurementApiRow {
measuredAt: string;
value: number;
// v1.8.5 — per-day min / max emitted by the rollup daily-aggregate
// path (non-cumulative metrics only). Drive the Apple-Health-style
// range band shaded around the mean line. Absent on the raw-row path
// (windows ≤ 7 days) and on cumulative metrics.
minValue?: number;
maxValue?: number;
// v1.4.43 W2-CHART-GATE — the rollup / daily-aggregate paths return
// the underlying raw-row count per bucket. Used downstream to gate
// the "more days needed" empty-state copy on the actual measurement
@@ -597,7 +606,19 @@ export function HealthChart({
string,
{
timestamp: number;
values: Record<string, { sum: number; count: number }>;
values: Record<
string,
{
sum: number;
count: number;
// v1.8.5 — per-bucket spread for the range band. Seeded
// from the rollup daily-aggregate's min / max columns;
// null when the API row carries no spread (raw-row path
// or cumulative metric).
min: number | null;
max: number | null;
}
>;
}
>();
@@ -674,9 +695,30 @@ export function HealthChart({
timestamp: dayKeyToTimestamp(dayKey),
values: {},
};
const current = bucket.values[type] ?? { sum: 0, count: 0 };
const current = bucket.values[type] ?? {
sum: 0,
count: 0,
min: null,
max: null,
};
current.sum += value;
current.count += 1;
// v1.8.5 — carry the rollup bucket's min / max through when the
// API supplies them (daily-aggregate path, non-cumulative).
// `valueScale` already folded into `value` above; apply the
// same scale to the spread so the band tracks the line.
if (
typeof measurement.minValue === "number" &&
typeof measurement.maxValue === "number" &&
valueMode !== "bmi"
) {
const scaledMin = measurement.minValue * valueScale;
const scaledMax = measurement.maxValue * valueScale;
current.min =
current.min === null ? scaledMin : Math.min(current.min, scaledMin);
current.max =
current.max === null ? scaledMax : Math.max(current.max, scaledMax);
}
bucket.values[type] = current;
dailyAggregates.set(dayKey, bucket);
}
@@ -702,6 +744,13 @@ export function HealthChart({
type as MeasurementType,
);
point[type] = isCumulative ? stats.sum : stats.sum / stats.count;
// v1.8.5 — emit the day's spread as a `[min, max]` tuple key
// so the range-band <Area> can shade it. Only when the
// rollup supplied both bounds and the metric is not
// cumulative (a SUM has no meaningful intra-day spread).
if (!isCumulative && stats.min !== null && stats.max !== null) {
point[`${type}__range`] = [stats.min, stats.max] as never;
}
}
return point;
@@ -1746,6 +1795,33 @@ export function HealthChart({
}}
/>
)}
{/* v1.8.5 — min–max range band. An Apple-Health-style
shaded area between each day's min and max, painted
behind the mean line so it reads as context, not a
second series. Gated on `!mini` (the dashboard
sparkline stays clean) and on the daily-aggregate
path supplying a `[min, max]` tuple per point — single
non-cumulative metrics with intra-day spread (pulse,
weight, glucose, BP). The toggle rides the existing
target-range pref so the user controls both bands
from one switch. */}
{!mini &&
showBands &&
types.map((type, i) => (
<Area
key={`${type}__range`}
type="monotone"
dataKey={`${type}__range`}
name={`${getTypeLabel(type, valueMode, t)} (${t("charts.rangeBand")})`}
stroke="none"
fill={colors[i % colors.length]}
fillOpacity={0.12}
connectNulls
isAnimationActive={animationsEnabled}
legendType="none"
tooltipType="none"
/>
))}
{types.map((type, i) => (
<Line
key={type}
@@ -20,6 +20,7 @@ function fakeSummary(count: number): DataSummary {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
@@ -30,6 +30,7 @@ function fakeSummary(count: number): DataSummary {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
import { MetricStatStrip } from "../metric-stat-strip";
/**
* v1.8.5 — `<MetricStatStrip>` SSR coverage.
*
* The strip leads the insights category pages with the four central
* stats — min / max / median / mean — read from the already-fetched
* `summaries` slice (zero new network). Acceptance:
* 1. Renders four labelled cells: min, max, median, mean.
* 2. Each cell shows the formatted value + the metric unit.
* 3. A null summary (read in flight) renders nothing rather than a
* strip full of em-dashes.
* 4. A zero-count summary renders nothing (brand-new metric, no data).
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const populated: DataSummary = {
count: 42,
latest: 72,
min: 58,
max: 88,
mean: 70.4,
median: 71,
avg7: 71,
avg30: 70,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
};
describe("<MetricStatStrip>", () => {
it("renders the four stat cells with min / max / median / mean", () => {
const html = render(<MetricStatStrip summary={populated} unit="bpm" />);
expect(html).toContain('data-stat="min"');
expect(html).toContain('data-stat="max"');
expect(html).toContain('data-stat="median"');
expect(html).toContain('data-stat="mean"');
});
it("shows the formatted values and the unit", () => {
const html = render(<MetricStatStrip summary={populated} unit="bpm" />);
expect(html).toContain("58");
expect(html).toContain("88");
expect(html).toContain("71");
expect(html).toContain("bpm");
});
it("renders nothing while the summary is still loading", () => {
const html = render(<MetricStatStrip summary={null} unit="bpm" />);
expect(html).toBe("");
});
it("renders nothing for a zero-count metric", () => {
const empty: DataSummary = { ...populated, count: 0 };
const html = render(<MetricStatStrip summary={empty} unit="bpm" />);
expect(html).toBe("");
});
it("falls back to mean when median is absent (older payload)", () => {
const noMedian: DataSummary = { ...populated, median: null };
const html = render(<MetricStatStrip summary={noMedian} unit="bpm" />);
// Median cell still paints (em-dash) so the four-up grid stays
// balanced; min / max / mean keep their values.
expect(html).toContain('data-stat="median"');
expect(html).toContain('data-stat="mean"');
});
});
@@ -41,6 +41,7 @@ const populatedSummary: DataSummary = {
min: 38.1,
max: 45.7,
mean: 41.5,
median: 41.4,
avg7: 42.0,
avg30: 41.8,
slope7: null,
@@ -57,6 +58,7 @@ const emptySummary: DataSummary = {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
@@ -13,6 +13,8 @@ import { Button } from "@/components/ui/button";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -107,7 +109,26 @@ export function HealthKitMetricPage({
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { isEmpty } = useInsightsAnalytics(insightMetric);
const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric);
const rawSummary = analytics?.summaries?.[measurementType] ?? null;
// The stat strip renders display-unit values. The summary holds stored
// values, so when the page renders a scaled unit (e.g. WALKING_SPEED
// stores m/s but displays km/h via `valueScale`), fold the same scale
// into the strip's min / max / median / mean so the numbers and the
// unit agree. `valueScale` defaults to 1 (identity) → byte-identical
// for every non-scaled metric.
const scale = valueScale ?? 1;
const summary =
rawSummary && scale !== 1
? {
...rawSummary,
min: rawSummary.min === null ? null : rawSummary.min * scale,
max: rawSummary.max === null ? null : rawSummary.max * scale,
mean: rawSummary.mean === null ? null : rawSummary.mean * scale,
median:
rawSummary.median === null ? null : rawSummary.median * scale,
}
: rawSummary;
const title = t(`${i18nPrefix}.title`);
const description = t(`${i18nPrefix}.description`);
@@ -143,6 +164,15 @@ export function HealthKitMetricPage({
title={title}
description={description}
explainerMetric={explainerMetric}
statStrip={<MetricStatStrip summary={summary} unit={yAxisUnit ?? unit} />}
diversityNudge={
<MeasurementDiversityNudge
measurementType={measurementType}
metricLabel={title}
timeZone={user?.timezone ?? undefined}
/>
}
showAllValuesType={measurementType}
>
<HealthChartDynamic
chartKey={chartKey}
@@ -0,0 +1,92 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Lightbulb } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { detectMeasurementDiversity } from "@/lib/insights/measurement-diversity";
/**
* v1.8.5 — measurement-diversity nudge for the insights category pages.
*
* When a metric's readings cluster on a single weekday or a narrow
* time-of-day band, the trend reads a biased slice of reality. This
* component fetches a bounded recent window of raw timestamps for the
* metric, runs the pure `detectMeasurementDiversity` check, and — only
* when the spread is genuinely lopsided — surfaces a gentle hint to
* measure on other days / times.
*
* v1.8.3 anti-freeze posture: the read is a bounded `limit`-capped query
* with a 5-minute `staleTime`, fires only after the page has mounted
* (no SSR blocking), and renders nothing while in flight or when the
* spread is healthy. No new server route — it rides the existing
* `/api/measurements` list endpoint.
*/
interface MeasurementDiversityNudgeProps {
/** The page's `MeasurementType`. */
measurementType: string;
/** Localised metric name woven into the hint copy. */
metricLabel: string;
/** User's IANA timezone so weekday / hour resolve in the wall clock. */
timeZone?: string;
}
/** Bounded window of recent rows — enough to read a pattern, never heavy. */
const WINDOW_SIZE = 120;
export function MeasurementDiversityNudge({
measurementType,
metricLabel,
timeZone,
}: MeasurementDiversityNudgeProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data: timestamps } = useQuery({
queryKey: queryKeys.measurementDiversity(measurementType),
queryFn: async (): Promise<string[]> => {
const params = new URLSearchParams();
params.set("type", measurementType);
params.set("limit", String(WINDOW_SIZE));
params.set("sortBy", "measuredAt");
params.set("sortDir", "desc");
const res = await fetch(`/api/measurements?${params}`);
if (!res.ok) return [];
const json = await res.json();
const rows = (json.data?.measurements ?? []) as Array<{
measuredAt: string;
}>;
return rows.map((r) => r.measuredAt);
},
enabled: isAuthenticated,
// The pattern shifts slowly — a 5-minute cache keeps the nudge calm
// and shares the read budget with the rest of the page.
staleTime: 5 * 60 * 1000,
});
if (!timestamps) return null;
const signal = detectMeasurementDiversity(timestamps, timeZone);
if (!signal) return null;
const message = t(`insights.subPage.diversity.${signal.kind}`, {
metric: metricLabel,
});
return (
<div
data-slot="measurement-diversity-nudge"
role="note"
className="bg-muted/40 text-muted-foreground flex items-start gap-2.5 rounded-lg px-3 py-2.5 text-sm"
>
<Lightbulb
className="text-dracula-yellow mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p className="leading-relaxed">{message}</p>
</div>
);
}
@@ -0,0 +1,98 @@
"use client";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.8.5 — at-a-glance stat strip for the insights category pages.
*
* Leads each metric page with the four central statistics — min / max /
* median / mean — read straight from the already-fetched `summaries`
* slice (`useInsightsAnalytics`). Zero new network: the layout shell and
* the empty-state gate already warm the `["analytics", "summaries"]`
* cache, so the strip is a pure render of cached numbers. This is the
* numbers-first header Apple Health / Withings / Oura lead their detail
* screens with — the single biggest "data-rich" lift per unit effort.
*
* Median sits alongside mean on purpose: a series with a handful of very
* high or very low readings drags the mean away from the typical value,
* and showing both makes that skew legible rather than hidden.
*
* The thin HealthKit pages (`HealthkitMetricPage`) mount this too so they
* reach parity with the bespoke pages — a rich numeric header even where
* there is no target band or AI assessment.
*/
interface MetricStatStripProps {
/**
* Analytics summary for the page's `MeasurementType`. Null is allowed
* so the parent doesn't have to gate on the network read finishing —
* the strip simply doesn't paint until the data lands. A zero-count
* summary (brand-new metric) also renders nothing; the page's
* empty-state owns that surface.
*/
summary: DataSummary | null;
/** Unit suffix rendered next to each value (e.g. `bpm`, `kg`). */
unit: string;
/**
* Decimal precision for the formatted values. Defaults to 1 — enough
* for weight (78.4) and HRV (41.5) without trailing noise on integer
* metrics, which `Intl.NumberFormat` drops anyway.
*/
fractionDigits?: number;
}
export function MetricStatStrip({
summary,
unit,
fractionDigits = 1,
}: MetricStatStripProps) {
const { t } = useTranslations();
const fmt = useFormatters();
// Nothing to show until the summary lands or for a metric with no
// readings — the page's empty-state covers the zero-data case.
if (!summary || summary.count <= 0) return null;
const format = (value: number | null): string =>
value === null ? "—" : `${fmt.number(value, fractionDigits)} ${unit}`;
const cells: Array<{ key: string; label: string; value: number | null }> = [
{ key: "min", label: t("insights.subPage.stats.min"), value: summary.min },
{ key: "max", label: t("insights.subPage.stats.max"), value: summary.max },
{
key: "median",
label: t("insights.subPage.stats.median"),
value: summary.median,
},
{
key: "mean",
label: t("insights.subPage.stats.mean"),
value: summary.mean,
},
];
return (
<section
data-slot="metric-stat-strip"
aria-label={t("insights.subPage.stats.label")}
className="bg-card border-border grid grid-cols-2 gap-3 rounded-xl border p-4 sm:grid-cols-4 [&>div]:min-h-[56px]"
>
{cells.map((cell) => (
<div
key={cell.key}
data-slot="metric-stat"
data-stat={cell.key}
className="space-y-1"
>
<p className="text-muted-foreground text-[10px] tracking-wide uppercase">
{cell.label}
</p>
<p className="text-lg font-semibold tabular-nums">
{format(cell.value)}
</p>
</div>
))}
</section>
);
}
@@ -1,8 +1,11 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import Link from "next/link";
import { ListOrdered } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MetricExplainer } from "@/components/insights/metric-explainer";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { useTranslations } from "@/lib/i18n/context";
@@ -66,6 +69,26 @@ export interface SubPageShellProps {
* affordance, not an a11y one.
*/
focusOnMount?: boolean;
/**
* v1.8.5 W4b — numbers-first stat strip rendered directly beneath the
* header, above the chart. Sub-pages pass `<MetricStatStrip metric=… />`
* so every metric (including the thin HealthKit pages) leads with
* min / max / median / mean. Omitted on the empty-state branch.
*/
statStrip?: ReactNode;
/**
* v1.8.5 W4b — measurement-diversity nudge mounted just under the stat
* strip. A gentle hint when readings cluster on one weekday / time.
* Self-gating: renders nothing when the spread is healthy.
*/
diversityNudge?: ReactNode;
/**
* v1.8.5 W4b — a "show all readings" entry rendered at the foot of the
* page, after the chart + cards. Sub-pages pass the metric's
* `MeasurementType`; the shell renders a button that links to the
* dedicated `/insights/values/<type>` subpage.
*/
showAllValuesType?: string;
children: ReactNode;
}
@@ -75,6 +98,9 @@ export function SubPageShell({
description,
explainerMetric,
focusOnMount = false,
statStrip,
diversityNudge,
showAllValuesType,
children,
}: SubPageShellProps) {
const { t } = useTranslations();
@@ -150,7 +176,30 @@ export function SubPageShell({
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
{/* v1.8.5 W4b — numbers-first stat strip + diversity nudge sit
between the header and the chart so the page leads with data,
mirroring the Apple Health / Withings detail layout. Both are
self-gating (render nothing without data), so the spacing
rhythm holds on the empty-state and brand-new-metric paths. */}
{statStrip}
{diversityNudge}
{children}
{/* v1.8.5 W4b — "show all readings" entry at the foot, linking to
the dedicated per-metric values subpage. */}
{showAllValuesType ? (
<Button
asChild
variant="outline"
size="sm"
data-slot="metric-show-all-values"
className="w-full sm:w-auto"
>
<Link href={`/insights/values/${showAllValuesType}`}>
<ListOrdered className="mr-1.5 size-4" aria-hidden="true" />
{t("insights.subPage.showAllValues")}
</Link>
</Button>
) : null}
</div>
);
}
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.8.5 — `<MeasurementList lockedType>` for the insights "all readings"
* subpage. When pinned to a type the global type selector is suppressed
* and every row belongs to that metric; the list still paginates and
* exposes inline edit/delete (unchanged from the unlocked path).
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/insights/values/WEIGHT",
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
measurements: [
{
id: "m1",
type: "WEIGHT",
value: 78.4,
unit: "kg",
source: "MANUAL",
measuredAt: "2026-05-30T08:00:00.000Z",
notes: null,
},
],
meta: { total: 1 },
},
isLoading: false,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "marc", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementList } from "../measurement-list";
function render(lockedType?: string) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementList lockedType={lockedType} />
</I18nProvider>,
);
}
describe("MeasurementList — lockedType", () => {
it("hides the type selector when locked to a metric", () => {
const locked = render("WEIGHT");
const unlocked = render();
// The unlocked list paints the filter trigger; the locked one does
// not (the metric is fixed by the route).
expect(unlocked).toContain("Filter by type");
expect(locked).not.toContain('role="combobox"');
});
it("loads the metric's readings", () => {
const html = render("WEIGHT");
// The single seeded WEIGHT row renders with its value + unit.
expect(html).toContain("78.4");
expect(html).toContain("kg");
});
});
@@ -103,6 +103,15 @@ interface MeasurementListProps {
* (fallback to the header CTA) instead of crashing.
*/
onAddFirst?: () => void;
/**
* v1.8.5 — pin the list to a single `MeasurementType` and hide the
* type selector. Used by the insights "all readings" subpage, which
* already knows the metric from the route and wants a focused list
* (pagination + inline edit/delete) rather than the global filter UI.
* When set, the type `Select` is not rendered and every fetch is
* scoped to this type; the count caption still shows.
*/
lockedType?: string;
}
const PAGE_SIZE = 25;
@@ -152,12 +161,20 @@ function sourceBadgeClass(source: string): string {
return "";
}
export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
export function MeasurementList({
onEdit,
onAddFirst,
lockedType,
}: MeasurementListProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [typeFilter, setTypeFilterRaw] = useState<string>("ALL");
// 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.
const [typeFilter, setTypeFilterRaw] = useState<string>(
lockedType ?? "ALL",
);
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<string>("measuredAt");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
@@ -419,22 +436,31 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
Pixel 5 (375 px content width) left only ~120 px for the
count, which wrapped to 2 lines. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger
className="w-full sm:w-48"
aria-label={t("measurements.filterByType")}
>
<SelectValue placeholder={t("measurements.allTypes")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">{t("measurements.allTypes")}</SelectItem>
{Object.entries(TYPE_LABEL_KEYS).map(([val, labelKey]) => (
<SelectItem key={val} value={val}>
{t(labelKey)}
{lockedType ? (
// v1.8.5 — the insights "all readings" subpage knows the
// metric from the route, so the global type selector is
// suppressed; the count caption still anchors the column.
<span className="sr-only">{t("measurements.filterByType")}</span>
) : (
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger
className="w-full sm:w-48"
aria-label={t("measurements.filterByType")}
>
<SelectValue placeholder={t("measurements.allTypes")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">
{t("measurements.allTypes")}
</SelectItem>
))}
</SelectContent>
</Select>
{Object.entries(TYPE_LABEL_KEYS).map(([val, labelKey]) => (
<SelectItem key={val} value={val}>
{t(labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{data?.meta?.total !== undefined && (
<span className="text-muted-foreground text-sm">
{t("measurements.measurementCount", {
@@ -457,17 +483,19 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
<EmptyState
icon={<Activity className="size-6" />}
title={
typeFilter === "ALL"
typeFilter === "ALL" || lockedType
? t("measurements.emptyTitle")
: t("measurements.emptyFilteredTitle")
}
description={
typeFilter === "ALL"
typeFilter === "ALL" || lockedType
? t("measurements.emptyDescription")
: t("measurements.emptyFilteredDescription")
}
action={
typeFilter !== "ALL" ? (
// v1.8.5 — a locked-type list has no "reset filter" path;
// the metric is fixed by the route.
typeFilter !== "ALL" && !lockedType ? (
<Button
variant="outline"
onClick={() => setTypeFilter("ALL")}