From 6ce09cc76da7dad5c6e31f1f8d1eaf8685b0eda8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 20:15:25 +0200 Subject: [PATCH] feat(insights): stat strip, range band, all-readings subpage, diversity nudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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. --- e2e/utils/mock-dashboard-snapshot.ts | 1 + messages/de.json | 20 ++- messages/en.json | 20 ++- messages/es.json | 20 ++- messages/fr.json | 20 ++- messages/it.json | 20 ++- messages/pl.json | 20 ++- src/app/api/measurements/route.ts | 11 ++ src/app/insights/pulse/page.tsx | 16 +++ src/app/insights/values/[type]/page.tsx | 67 ++++++++++ src/app/insights/weight/page.tsx | 14 +- src/components/charts/health-chart.tsx | 82 +++++++++++- .../insights-tab-strip-memo.test.tsx | 1 + .../__tests__/insights-tab-strip.test.tsx | 1 + .../__tests__/metric-stat-strip.test.tsx | 81 ++++++++++++ .../__tests__/vo2-max-chart-row.test.tsx | 2 + .../insights/healthkit-metric-page.tsx | 32 ++++- .../insights/measurement-diversity-nudge.tsx | 92 +++++++++++++ src/components/insights/metric-stat-strip.tsx | 98 ++++++++++++++ src/components/insights/sub-page-shell.tsx | 49 +++++++ .../measurement-list-locked-type.test.tsx | 79 +++++++++++ .../measurements/measurement-list.tsx | 68 +++++++--- .../__tests__/merge-slim-thick.test.ts | 1 + .../__tests__/summaries-slice.test.ts | 5 + src/lib/analytics/__tests__/trends.test.ts | 24 ++++ src/lib/analytics/summaries-slice.ts | 23 ++++ src/lib/analytics/trends.ts | 22 +++ .../__tests__/measurement-diversity.test.ts | 65 +++++++++ .../__tests__/metric-availability.test.ts | 1 + src/lib/insights/comprehensive-aggregator.ts | 8 ++ src/lib/insights/measurement-diversity.ts | 125 ++++++++++++++++++ src/lib/query-keys.ts | 12 ++ 32 files changed, 1063 insertions(+), 37 deletions(-) create mode 100644 src/app/insights/values/[type]/page.tsx create mode 100644 src/components/insights/__tests__/metric-stat-strip.test.tsx create mode 100644 src/components/insights/measurement-diversity-nudge.tsx create mode 100644 src/components/insights/metric-stat-strip.tsx create mode 100644 src/components/measurements/__tests__/measurement-list-locked-type.test.tsx create mode 100644 src/lib/insights/__tests__/measurement-diversity.test.ts create mode 100644 src/lib/insights/measurement-diversity.ts diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index 5bc9aa094..29043ee61 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -37,6 +37,7 @@ function summary(partial: Partial): DataSummary { min: null, max: null, mean: null, + median: null, avg7: null, avg30: null, slope7: null, diff --git a/messages/de.json b/messages/de.json index f53fb4e6c..b77f2b496 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1408,7 +1408,8 @@ "fri": "Freitag", "sat": "Samstag", "sun": "Sonntag" - } + }, + "rangeBand": "Bereich" }, "chart": { "overlay": { @@ -1956,7 +1957,22 @@ "bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein; dauerhaft hohe Werte können auf Diabetes oder Prädiabetes hinweisen.", "skinTemperatureTitle": "Hauttemperatur", "skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln." - } + }, + "stats": { + "label": "Zusammenfassende Statistik", + "min": "Min", + "max": "Max", + "median": "Median", + "mean": "Mittelwert" + }, + "showAllValues": "Alle Werte anzeigen", + "valuesPageTitle": "{metric} — alle Werte", + "valuesPageDescription": "Jeder einzelne Wert hinter dieser Kennzahl. Einträge lassen sich direkt bearbeiten oder entfernen.", + "diversity": { + "weekday": "Die meisten deiner {metric}-Werte fallen auf einen Wochentag. Messungen an anderen Tagen ergeben ein vollständigeres Bild.", + "timeOfDay": "Die meisten deiner {metric}-Werte fallen auf eine ähnliche Tageszeit. Unterschiedliche Zeiten ergeben ein vollständigeres Bild." + }, + "valuesBack": "Zurück zu Insights" }, "sleep": { "title": "Schlaf", diff --git a/messages/en.json b/messages/en.json index f64dbe583..29888b6f3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1408,7 +1408,8 @@ "fri": "Friday", "sat": "Saturday", "sun": "Sunday" - } + }, + "rangeBand": "Range" }, "chart": { "overlay": { @@ -1956,7 +1957,22 @@ "bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals; readings that stay high over time can signal diabetes or prediabetes.", "skinTemperatureTitle": "Skin temperature", "skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase." - } + }, + "stats": { + "label": "Summary statistics", + "min": "Min", + "max": "Max", + "median": "Median", + "mean": "Mean" + }, + "showAllValues": "Show all readings", + "valuesPageTitle": "{metric} — all readings", + "valuesPageDescription": "Every reading behind this metric. Edit or remove an entry directly.", + "diversity": { + "weekday": "Most of your {metric} readings land on one weekday. Logging on other days gives a fuller picture.", + "timeOfDay": "Most of your {metric} readings land at a similar time of day. Varying the time gives a fuller picture." + }, + "valuesBack": "Back to insights" }, "sleep": { "title": "Sleep", diff --git a/messages/es.json b/messages/es.json index 1c96e71c4..dfe8993b0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1408,7 +1408,8 @@ "fri": "Viernes", "sat": "Sábado", "sun": "Domingo" - } + }, + "rangeBand": "Rango" }, "chart": { "overlay": { @@ -1956,7 +1957,22 @@ "bloodGlucoseBody": "La glucosa en sangre es la cantidad de azúcar en tu sangre, la principal fuente de energía del cuerpo. Sube tras comer y se estabiliza entre comidas; valores que se mantienen altos con el tiempo pueden indicar diabetes o prediabetes.", "skinTemperatureTitle": "Temperatura de la piel", "skinTemperatureBody": "La temperatura de la piel aquí es el pequeño cambio nocturno de la temperatura de tu muñeca respecto a tu propio valor de referencia, no una medición absoluta de fiebre. Las variaciones pueden seguir el sueño, el entorno, una enfermedad o la fase del ciclo menstrual." - } + }, + "stats": { + "label": "Estadísticas resumidas", + "min": "Mín", + "max": "Máx", + "median": "Mediana", + "mean": "Media" + }, + "showAllValues": "Mostrar todas las mediciones", + "valuesPageTitle": "{metric} — todas las mediciones", + "valuesPageDescription": "Cada medición detrás de esta métrica. Edita o elimina una entrada directamente.", + "diversity": { + "weekday": "La mayoría de tus mediciones de {metric} caen en un mismo día de la semana. Registrar en otros días ofrece una imagen más completa.", + "timeOfDay": "La mayoría de tus mediciones de {metric} caen a una hora similar del día. Variar la hora ofrece una imagen más completa." + }, + "valuesBack": "Volver a Insights" }, "sleep": { "title": "Sueño", diff --git a/messages/fr.json b/messages/fr.json index 8063ccc6f..7ae6e73a1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1408,7 +1408,8 @@ "fri": "Vendredi", "sat": "Samedi", "sun": "Dimanche" - } + }, + "rangeBand": "Plage" }, "chart": { "overlay": { @@ -1956,7 +1957,22 @@ "bloodGlucoseBody": "La glycémie est la quantité de sucre dans votre sang, principale source d'énergie de l'organisme. Elle augmente après les repas et se stabilise entre eux ; des valeurs durablement élevées peuvent signaler un diabète ou un prédiabète.", "skinTemperatureTitle": "Température cutanée", "skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel." - } + }, + "stats": { + "label": "Statistiques récapitulatives", + "min": "Min", + "max": "Max", + "median": "Médiane", + "mean": "Moyenne" + }, + "showAllValues": "Afficher toutes les mesures", + "valuesPageTitle": "{metric} — toutes les mesures", + "valuesPageDescription": "Chaque mesure derrière cette métrique. Modifiez ou supprimez une entrée directement.", + "diversity": { + "weekday": "La plupart de vos mesures de {metric} tombent le même jour de la semaine. Enregistrer d'autres jours donne une image plus complète.", + "timeOfDay": "La plupart de vos mesures de {metric} tombent à une heure similaire de la journée. Varier l'heure donne une image plus complète." + }, + "valuesBack": "Retour aux Insights" }, "sleep": { "title": "Sommeil", diff --git a/messages/it.json b/messages/it.json index 2b00fb2b2..d290ac44f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1408,7 +1408,8 @@ "fri": "Venerdì", "sat": "Sabato", "sun": "Domenica" - } + }, + "rangeBand": "Intervallo" }, "chart": { "overlay": { @@ -1956,7 +1957,22 @@ "bloodGlucoseBody": "La glicemia è la quantità di zucchero nel sangue, la principale fonte di energia del corpo. Sale dopo i pasti e si stabilizza tra un pasto e l'altro; valori che restano alti nel tempo possono indicare diabete o prediabete.", "skinTemperatureTitle": "Temperatura cutanea", "skinTemperatureBody": "La temperatura cutanea qui è la piccola variazione notturna della temperatura del polso rispetto al tuo valore di riferimento, non una misura assoluta della febbre. Le variazioni possono seguire sonno, ambiente, malattia o fase del ciclo mestruale." - } + }, + "stats": { + "label": "Statistiche riepilogative", + "min": "Min", + "max": "Max", + "median": "Mediana", + "mean": "Media" + }, + "showAllValues": "Mostra tutte le misurazioni", + "valuesPageTitle": "{metric} — tutte le misurazioni", + "valuesPageDescription": "Ogni misurazione dietro questa metrica. Modifica o rimuovi una voce direttamente.", + "diversity": { + "weekday": "La maggior parte delle tue misurazioni di {metric} cade in un solo giorno della settimana. Registrare in altri giorni offre un quadro più completo.", + "timeOfDay": "La maggior parte delle tue misurazioni di {metric} cade in un orario simile della giornata. Variare l'orario offre un quadro più completo." + }, + "valuesBack": "Torna a Insights" }, "sleep": { "title": "Sonno", diff --git a/messages/pl.json b/messages/pl.json index a34c6ad4e..c511d661f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1408,7 +1408,8 @@ "fri": "Piątek", "sat": "Sobota", "sun": "Niedziela" - } + }, + "rangeBand": "Zakres" }, "chart": { "overlay": { @@ -1956,7 +1957,22 @@ "bloodGlucoseBody": "Poziom glukozy we krwi to ilość cukru we krwi, głównego źródła energii organizmu. Rośnie po jedzeniu i ustala się między posiłkami; wartości utrzymujące się wysoko mogą sygnalizować cukrzycę lub stan przedcukrzycowy.", "skinTemperatureTitle": "Temperatura skóry", "skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego." - } + }, + "stats": { + "label": "Statystyki podsumowujące", + "min": "Min", + "max": "Maks", + "median": "Mediana", + "mean": "Średnia" + }, + "showAllValues": "Pokaż wszystkie pomiary", + "valuesPageTitle": "{metric} — wszystkie pomiary", + "valuesPageDescription": "Każdy pomiar stojący za tą metryką. Edytuj lub usuń wpis bezpośrednio.", + "diversity": { + "weekday": "Większość Twoich pomiarów {metric} przypada na jeden dzień tygodnia. Pomiary w inne dni dają pełniejszy obraz.", + "timeOfDay": "Większość Twoich pomiarów {metric} przypada o podobnej porze dnia. Różne pory dają pełniejszy obraz." + }, + "valuesBack": "Powrót do Insights" }, "sleep": { "title": "Sen", diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 7331f81c8..ce3a032fb 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -328,6 +328,8 @@ export const GET = apiHandler(async (request: NextRequest) => { // legacy fallback below covers the boot-backfill convergence // window when an existing bucket pre-dates v1.4.39. sumValue: true, + minValue: true, + maxValue: true, }, }); // v1.4.39.2 — coverage-mismatch fallback. v1.4.39.1 wired the @@ -417,6 +419,8 @@ export const GET = apiHandler(async (request: NextRequest) => { mean: true, count: true, sumValue: true, + minValue: true, + maxValue: true, }, }); } catch (err) { @@ -454,6 +458,13 @@ export const GET = apiHandler(async (request: NextRequest) => { : r.mean, measuredAt: r.bucketStart.toISOString(), count: r.count, + // v1.8.5 — per-day min / max so the chart can shade an + // Apple-Health-style range band around the daily mean line. + // Omitted for cumulative metrics (steps, distance, …) where the + // daily value is a SUM, not a central tendency, so an intra-day + // spread band carries no meaning. + minValue: useSum ? undefined : r.minValue, + maxValue: useSum ? undefined : r.maxValue, })); annotate({ action: { name: "measurement.list" }, diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 073bf3dca..a36cb2a64 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -13,6 +13,8 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; 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 { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row"; @@ -114,6 +116,20 @@ export default function InsightsPulsPage() { title={t("insights.pulseSectionTitle")} description={t("insights.subPage.pulsDescription")} explainerMetric="pulse" + statStrip={ + + } + diversityNudge={ + + } + showAllValuesType="PULSE" > ` to that type via `lockedType`, giving the user the + * full paginated, inline-editable raw-readings view without rebuilding + * any of the list machinery. + * + * A single dynamic route serves every metric — the enum guard rejects an + * unknown segment with a 404 rather than rendering an empty list for a + * typo'd type. + */ +export default function InsightsMetricValuesPage({ + params, +}: { + params: Promise<{ type: string }>; +}) { + const { t } = useTranslations(); + const { type } = use(params); + + // Reject any segment that is not a real MeasurementType so a typo'd + // deep-link 404s instead of mounting a blank list. + if (!measurementTypeEnum.options.includes(type as never)) { + notFound(); + } + + const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type]; + const metricLabel = labelKey ? t(labelKey) : type; + + return ( + + + + + + ); +} diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index c150d178f..209622e39 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -13,6 +13,8 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; 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 { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands"; @@ -37,7 +39,8 @@ export default function InsightsGewichtPage() { const { data: status, isLoading: isStatusLoading } = useInsightStatus("weight"); - const { isEmpty } = useInsightsAnalytics("WEIGHT"); + const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT"); + const weightSummary = analytics?.summaries?.WEIGHT ?? null; if (isEmpty) { return ( @@ -75,6 +78,15 @@ export default function InsightsGewichtPage() { title={t("insights.weightSectionTitle")} description={t("insights.subPage.gewichtDescription")} explainerMetric="weight" + statStrip={} + diversityNudge={ + + } + showAllValuesType="WEIGHT" > ; 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; + 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 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) => ( + + ))} {types.map((type, i) => ( ` 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( + {node}, + ); +} + +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("", () => { + it("renders the four stat cells with min / max / median / mean", () => { + const html = render(); + 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(); + 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(); + expect(html).toBe(""); + }); + + it("renders nothing for a zero-count metric", () => { + const empty: DataSummary = { ...populated, count: 0 }; + const html = render(); + expect(html).toBe(""); + }); + + it("falls back to mean when median is absent (older payload)", () => { + const noMedian: DataSummary = { ...populated, median: null }; + const html = render(); + // 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"'); + }); +}); diff --git a/src/components/insights/__tests__/vo2-max-chart-row.test.tsx b/src/components/insights/__tests__/vo2-max-chart-row.test.tsx index 778bb20aa..7c486ebb2 100644 --- a/src/components/insights/__tests__/vo2-max-chart-row.test.tsx +++ b/src/components/insights/__tests__/vo2-max-chart-row.test.tsx @@ -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, diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index ce776b03f..2b0f0ad0d 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -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={} + diversityNudge={ + + } + showAllValuesType={measurementType} > => { + 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 ( +
+
+ ); +} diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx new file mode 100644 index 000000000..40e5789bd --- /dev/null +++ b/src/components/insights/metric-stat-strip.tsx @@ -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 ( +
+ {cells.map((cell) => ( +
+

+ {cell.label} +

+

+ {format(cell.value)} +

+
+ ))} +
+ ); +} diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index 52f662488..1bb8454d6 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -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 `` + * 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/` 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({

{description}

) : null} + {/* 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 ? ( + + ) : null} ); } diff --git a/src/components/measurements/__tests__/measurement-list-locked-type.test.tsx b/src/components/measurements/__tests__/measurement-list-locked-type.test.tsx new file mode 100644 index 000000000..97ac77da4 --- /dev/null +++ b/src/components/measurements/__tests__/measurement-list-locked-type.test.tsx @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * v1.8.5 — `` 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( + + + , + ); +} + +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"); + }); +}); diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 816b78c8c..60019e5da 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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("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( + lockedType ?? "ALL", + ); const [page, setPage] = useState(1); const [sortBy, setSortBy] = useState("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. */}
- + + + + + + {t("measurements.allTypes")} - ))} - - + {Object.entries(TYPE_LABEL_KEYS).map(([val, labelKey]) => ( + + {t(labelKey)} + + ))} + + + )} {data?.meta?.total !== undefined && ( {t("measurements.measurementCount", { @@ -457,17 +483,19 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) { } 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 ? (