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

+1
View File
@@ -37,6 +37,7 @@ function summary(partial: Partial<DataSummary>): DataSummary {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
+18 -2
View File
@@ -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",
+18 -2
View File
@@ -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",
+18 -2
View File
@@ -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",
+18 -2
View File
@@ -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",
+18 -2
View File
@@ -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",
+18 -2
View File
@@ -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",
+11
View File
@@ -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" },
+16
View File
@@ -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={
<MetricStatStrip
summary={analytics?.summaries?.PULSE ?? null}
unit="bpm"
/>
}
diversityNudge={
<MeasurementDiversityNudge
measurementType="PULSE"
metricLabel={t("insights.pulseSectionTitle")}
timeZone={user?.timezone ?? undefined}
/>
}
showAllValuesType="PULSE"
>
<HealthChartDynamic
chartKey="pulse"
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { use } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { Button } from "@/components/ui/button";
import { MeasurementList } from "@/components/measurements/measurement-list";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
* v1.8.5 — `/insights/values/[type]`.
*
* The "show all readings" subpage for a single metric. The insights
* category pages link here with their `MeasurementType` as the route
* segment (e.g. `/insights/values/WEIGHT`); this page pins the existing
* `<MeasurementList>` 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 (
<SubPageShell
title={t("insights.subPage.valuesPageTitle", { metric: metricLabel })}
description={t("insights.subPage.valuesPageDescription")}
>
<Button
asChild
variant="ghost"
size="sm"
data-slot="metric-values-back"
className="-ml-2 w-fit"
>
<Link href="/insights">
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
{t("insights.subPage.valuesBack")}
</Link>
</Button>
<MeasurementList lockedType={type} />
</SubPageShell>
);
}
+13 -1
View File
@@ -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={<MetricStatStrip summary={weightSummary} unit="kg" />}
diversityNudge={
<MeasurementDiversityNudge
measurementType="WEIGHT"
metricLabel={t("insights.weightSectionTitle")}
timeZone={user?.timezone ?? undefined}
/>
}
showAllValuesType="WEIGHT"
>
<HealthChartDynamic
chartKey="weight"
+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")}
@@ -24,6 +24,7 @@ const stubSummary = (): DataSummary => ({
min: 76,
max: 80,
mean: 78,
median: 78,
avg7: 78.2,
avg30: 77.9,
slope7: null,
@@ -74,6 +74,7 @@ describe("computeSummariesSlice", () => {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
@@ -273,6 +274,7 @@ describe("computeSummariesSlice", () => {
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
median: 82.1,
slope7: 0.02,
r2_7: 0.5,
slope30: 0.01,
@@ -307,6 +309,9 @@ describe("computeSummariesSlice", () => {
expect(weight.min).toBe(79.5);
expect(weight.max).toBe(84.0);
expect(weight.mean).toBe(82);
// v1.8.5 — the windowed median flows through from the narrow
// `PERCENTILE_CONT` column onto the slim summary.
expect(weight.median).toBe(82.1);
expect(weight.latest).toBe(82.7);
expect(weight.avg7).toBe(82);
expect(weight.slope7).toEqual({
@@ -76,6 +76,7 @@ describe("summarize", () => {
expect(summary.min).toBeNull();
expect(summary.max).toBeNull();
expect(summary.mean).toBeNull();
expect(summary.median).toBeNull();
expect(summary.avg7).toBeNull();
expect(summary.avg30).toBeNull();
});
@@ -88,10 +89,33 @@ describe("summarize", () => {
expect(summary.min).toBe(70);
expect(summary.max).toBe(80);
expect(summary.mean).toBe(75);
// 11 values — odd length, median is the 6th value (75).
expect(summary.median).toBe(75);
expect(summary.avg7).not.toBeNull();
expect(summary.slope7).not.toBeNull();
});
// v1.8.5 — median powers the category-page stat strip. It must be the
// 50th percentile over VALUES (not timestamps) and must resist a skew
// the mean would otherwise hide.
it("computes the median over an even-length series as the midpoint", () => {
// Four values; median = (72 + 74) / 2 = 73. Insertion order is
// intentionally not sorted to prove the helper sorts by value.
const data = makePoints([74, 70, 72, 76], 4);
const summary = summarize(data);
expect(summary.median).toBe(73);
});
it("median diverges from the mean on a right-skewed series", () => {
// One large outlier drags the mean up; the median stays at the
// central reading — exactly the honesty the stat strip surfaces.
const data = makePoints([10, 11, 12, 13, 200], 5);
const summary = summarize(data);
expect(summary.median).toBe(12);
expect(summary.mean).not.toBe(summary.median);
expect(summary.mean as number).toBeGreaterThan(summary.median as number);
});
// v3 audit caught a divergence: summarize().avg7 used `now` as the
// window anchor, while trendSlope() used the last point. A stale
// series (no readings for weeks) reported a slope but no average.
+23
View File
@@ -109,6 +109,16 @@ interface NarrowAggregateRow {
type: string;
avg7: number | null;
avg30: number | null;
/**
* v1.8.5 — 50th percentile over the trailing 90-day window. The
* windowed median is cheap (the narrow query already index-scans the
* 90-day partition) and represents the central reading the stat strip
* surfaces next to min / max / mean. An all-time median would require
* sorting every raw row per type, which the rollup tier cannot
* compose and which the perf posture (v1.8.3 anti-freeze) forbids on
* the read path. Null when the window holds no rows.
*/
median: number | null;
avg30_last_month: number | null;
slope7: number | null;
r2_7: number | null;
@@ -159,6 +169,7 @@ function emptySummary(): DataSummary {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
@@ -271,6 +282,11 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
WHERE m."measured_at" >= NOW() - INTERVAL '60 days'
AND m."measured_at" < NOW() - INTERVAL '30 days'
)::double precision AS avg30_last_month,
PERCENTILE_CONT(0.5) WITHIN GROUP (
ORDER BY m."value"
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
)::double precision AS median,
REGR_SLOPE(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
@@ -414,6 +430,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
min: round2(row.min),
max: round2(row.max),
mean: round2(row.mean),
median: round2(narrow?.median ?? null),
avg7: round2(narrow?.avg7 ?? null),
avg30: round2(narrow?.avg30 ?? null),
slope7: buildSlope(narrow?.slope7 ?? null, narrow?.r2_7 ?? null),
@@ -509,6 +526,11 @@ async function computeFromLiveAggregate(
WHERE m."measured_at" >= NOW() - INTERVAL '60 days'
AND m."measured_at" < NOW() - INTERVAL '30 days'
)::double precision AS avg30_last_month,
PERCENTILE_CONT(0.5) WITHIN GROUP (
ORDER BY m."value"
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
)::double precision AS median,
REGR_SLOPE(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
@@ -617,6 +639,7 @@ async function computeFromLiveAggregate(
min: round2(row.min_value),
max: round2(row.max_value),
mean: round2(row.mean_value),
median: round2(win?.median ?? null),
avg7: round2(win?.avg7 ?? null),
avg30: round2(win?.avg30 ?? null),
slope7: buildSlope(win?.slope7 ?? null, win?.r2_7 ?? null),
+22
View File
@@ -120,6 +120,13 @@ export interface DataSummary {
max: number | null;
/** Null when the series is empty (vs the previous 0 sentinel). */
mean: number | null;
/**
* v1.8.5 — 50th percentile (median) of the value series. Surfaced on
* the insights category-page stat strip alongside min / max / mean so
* a skewed series (a few very high or very low readings dragging the
* mean) reads honestly. Null when the series is empty.
*/
median: number | null;
avg7: number | null;
avg30: number | null;
slope7: TrendSlope | null;
@@ -152,6 +159,7 @@ export function summarize(data: DataPoint[]): DataSummary {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
@@ -184,6 +192,19 @@ export function summarize(data: DataPoint[]): DataSummary {
}
const mean = sum / values.length;
// v1.8.5 — median (50th percentile) over the full series. A separate
// numeric sort from the date-ordered `sorted` array above so the
// percentile is taken over values, not timestamps. Linear-interpolated
// midpoint on an even-length series (the standard PERCENTILE_CONT
// definition) so this matches the SQL `PERCENTILE_CONT(0.5)` the
// rollup-tier slim slice emits.
const valueSorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(valueSorted.length / 2);
const median =
valueSorted.length % 2 === 0
? (valueSorted[mid - 1] + valueSorted[mid]) / 2
: valueSorted[mid];
const now = Date.now();
const DAY = 24 * 60 * 60 * 1000;
const last7 = sorted.filter((p) => now - p.date.getTime() < 7 * DAY);
@@ -215,6 +236,7 @@ export function summarize(data: DataPoint[]): DataSummary {
min: minVal,
max: maxVal,
mean: Math.round(mean * 100) / 100,
median: Math.round(median * 100) / 100,
avg7:
last7.length > 0
? Math.round(
@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { detectMeasurementDiversity } from "../measurement-diversity";
/**
* v1.8.5 — measurement-diversity detection.
*
* The nudge fires when a metric's readings cluster on a single weekday
* or a narrow time-of-day band, hinting the user to spread their
* measurements for a fuller picture. Pure function over ISO timestamps
* so the unit test pins the thresholds without a DOM.
*/
// Build N ISO timestamps, all on the same weekday (Tuesdays) at 09:00.
function tuesdaysAt(hour: number, count: number): string[] {
// 2026-06-02 is a Tuesday.
const out: string[] = [];
for (let i = 0; i < count; i++) {
const d = new Date(2026, 5, 2 + i * 7, hour, 0, 0);
out.push(d.toISOString());
}
return out;
}
// Build N timestamps spread across every weekday at varied hours.
function spreadAcrossWeek(count: number): string[] {
const out: string[] = [];
for (let i = 0; i < count; i++) {
const d = new Date(2026, 5, 1 + i, 6 + (i % 12), 0, 0);
out.push(d.toISOString());
}
return out;
}
describe("detectMeasurementDiversity", () => {
it("returns null below the minimum sample floor", () => {
expect(detectMeasurementDiversity(tuesdaysAt(9, 3))).toBeNull();
});
it("flags a weekday cluster when most readings fall on one day", () => {
const result = detectMeasurementDiversity(tuesdaysAt(9, 10));
expect(result).not.toBeNull();
expect(result?.kind).toBe("weekday");
});
it("flags a time-of-day cluster when the weekday is spread", () => {
// Spread across weekdays but every reading at 08:00 → time cluster.
const sameHour: string[] = [];
for (let i = 0; i < 12; i++) {
sameHour.push(new Date(2026, 5, 1 + i, 8, 0, 0).toISOString());
}
const result = detectMeasurementDiversity(sameHour);
expect(result?.kind).toBe("timeOfDay");
});
it("returns null for well-spread readings", () => {
expect(detectMeasurementDiversity(spreadAcrossWeek(14))).toBeNull();
});
it("ignores invalid timestamps without throwing", () => {
const mixed = [...tuesdaysAt(9, 10), "not-a-date", ""];
const result = detectMeasurementDiversity(mixed);
expect(result?.kind).toBe("weekday");
});
});
@@ -19,6 +19,7 @@ function fakeSummary(count: number): DataSummary {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
@@ -490,6 +490,11 @@ async function buildFromRollups(
min: round2(composed.min),
max: round2(composed.max),
mean: round2(composed.mean),
// v1.8.5 — the comprehensive aggregator feeds the AI snapshot, not
// the category-page stat strip, and does not compute a percentile.
// Left null so the shared `DataSummary` contract holds without
// paying a per-type percentile sort on the AI read path.
median: null,
avg7: round2(narrow?.avg7 ?? null),
avg30: round2(narrow?.avg30 ?? null),
slope7: buildSlope(narrow?.slope7 ?? null, narrow?.r2_7 ?? null),
@@ -662,6 +667,9 @@ async function buildFromLiveAggregate(
min: round2(row.min_value),
max: round2(row.max_value),
mean: round2(row.mean_value),
// v1.8.5 — see the parallel block above: the AI snapshot path
// omits the percentile.
median: null,
avg7: round2(row.avg7),
avg30: round2(row.avg30),
slope7: buildSlope(row.slope7, row.r2_7),
+125
View File
@@ -0,0 +1,125 @@
/**
* v1.8.5 — measurement-diversity detection for the insights category
* pages.
*
* When a user's readings for a metric cluster on a single weekday (only
* Tuesdays) or a narrow time-of-day band (always right after waking),
* the trend reads a biased slice of reality. This helper inspects the
* timestamps of a bounded recent window and, if the spread is too
* narrow, returns a hint the page surfaces as a gentle nudge to measure
* on other days / times.
*
* Pure + deterministic over ISO timestamp strings so it is trivially
* unit-tested and cache-friendly: the component computes it from the
* raw rows it already has, with no blocking work and no extra round-trip
* beyond the bounded list fetch it shares with the values subpage.
*/
export type DiversityKind = "weekday" | "timeOfDay";
export interface DiversitySignal {
kind: DiversityKind;
/** Share (0–1) of readings that fell in the dominant bucket. */
share: number;
}
/**
* Minimum number of valid readings before the nudge can fire. Below
* this the clustering is not yet meaningful — a handful of readings on
* one day is normal for a brand-new metric.
*/
const MIN_SAMPLES = 8;
/**
* Fraction of readings that must fall in a single bucket for the bucket
* to count as a cluster. 0.7 keeps the nudge conservative — it only
* fires when the spread is genuinely lopsided.
*/
const CLUSTER_THRESHOLD = 0.7;
/**
* Time-of-day bands (in hours). A reading clusters by time when ≥
* `CLUSTER_THRESHOLD` of readings fall in the same 6-hour band.
*/
const TIME_BAND_HOURS = 6;
interface LocalParts {
weekday: number; // 0–6
hour: number; // 0–23
}
/**
* Resolve a timestamp's weekday + hour in the given IANA timezone, or
* the runtime-local zone when none is supplied. Returns null for an
* unparseable timestamp so the caller can skip it.
*/
function localParts(iso: string, timeZone?: string): LocalParts | null {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return null;
if (!timeZone) {
return { weekday: date.getDay(), hour: date.getHours() };
}
// Intl resolves the wall-clock weekday + hour in the target zone.
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "short",
hour: "numeric",
hour12: false,
}).formatToParts(date);
const weekdayName = parts.find((p) => p.type === "weekday")?.value ?? "";
const hourStr = parts.find((p) => p.type === "hour")?.value ?? "0";
const weekdayMap: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
const weekday = weekdayMap[weekdayName] ?? 0;
// `hour12: false` can emit "24" for midnight in some engines.
const hour = Number(hourStr) % 24;
return { weekday, hour: Number.isFinite(hour) ? hour : 0 };
}
/**
* Inspect the timestamps and return the dominant clustering signal, or
* null when the readings are well spread (or below the sample floor).
* Weekday clustering takes precedence — it is the coarser, more
* actionable bias ("you only measure on Tuesdays").
*/
export function detectMeasurementDiversity(
timestamps: ReadonlyArray<string>,
timeZone?: string,
): DiversitySignal | null {
const parts: LocalParts[] = [];
for (const iso of timestamps) {
const p = localParts(iso, timeZone);
if (p) parts.push(p);
}
if (parts.length < MIN_SAMPLES) return null;
// Weekday histogram.
const weekdayCounts = new Array<number>(7).fill(0);
for (const p of parts) weekdayCounts[p.weekday] += 1;
const topWeekday = Math.max(...weekdayCounts);
const weekdayShare = topWeekday / parts.length;
if (weekdayShare >= CLUSTER_THRESHOLD) {
return { kind: "weekday", share: weekdayShare };
}
// Time-of-day histogram (6-hour bands → 4 buckets).
const bandCount = Math.ceil(24 / TIME_BAND_HOURS);
const bandCounts = new Array<number>(bandCount).fill(0);
for (const p of parts) {
bandCounts[Math.floor(p.hour / TIME_BAND_HOURS) % bandCount] += 1;
}
const topBand = Math.max(...bandCounts);
const timeShare = topBand / parts.length;
if (timeShare >= CLUSTER_THRESHOLD) {
return { kind: "timeOfDay", share: timeShare };
}
return null;
}
+12
View File
@@ -374,6 +374,16 @@ export const queryKeys = {
toIso,
valueScale,
] as const,
/**
* v1.8.5 — bounded recent-timestamp read powering the
* measurement-diversity nudge on an insights category page. Keyed by
* the page's `MeasurementType` so each metric caches its own window.
* Shares the `measurement-dependent` invalidation prefix below so an
* edit/delete in the values subpage re-runs the clustering check.
*/
measurementDiversity: (type: string) =>
["measurement-diversity", type] as const,
};
/**
@@ -394,6 +404,8 @@ export const measurementDependentKeys = [
queryKeys.insightsTargets(),
queryKeys.gamificationAchievements(),
["chart-data"] as const,
// v1.8.5 — re-run the diversity-nudge clustering when readings change.
["measurement-diversity"] as const,
];
/**