mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
1 parent
cf649f72ce
commit
6ce09cc76d
32 files changed
+1063
-37
No files matched your search
@@ -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")}
|
||||
|
||||
Reference in new issue
Block a user