mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(insights): stat strip, range band, all-readings subpage, diversity nudge
Add the data-value features to the insights category pages on top of the density pass: - Stat strip: lead each metric page with min / max / median / mean, read from the already-fetched analytics summaries slice (no new network). Median is now computed in both summary producers — exact in the live path, a windowed PERCENTILE_CONT in the rollup-tier slim slice. The thin HealthKit pages mount it too for parity; no AI assessment is added there. - Range band: shade an Apple-Health-style min-max area around the daily mean line where the data supports it (non-cumulative metrics). The daily-aggregate read now emits per-day min / max from the rollup buckets; the band rides the existing target-range toggle. - All readings: a dedicated per-metric subpage at /insights/values/<type> reusing the measurement list pinned to one type (pagination + inline edit/delete). A clear entry button mounts at the foot of each category page. - Diversity nudge: a gentle hint when a metric's readings cluster on one weekday or time of day, computed from a bounded, cached timestamp window — non-blocking, self-gating. All new reads stay cache-friendly and off the navigation path.
This commit is contained in:
1 parent
cf649f72ce
commit
6ce09cc76d
32 files changed
+1063
-37
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user