diff --git a/messages/de.json b/messages/de.json index c0d5433c1..f35254e57 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2198,14 +2198,14 @@ "noData": "Keine Daten im gewählten Zeitraum", "bucketWeekly": "Wochendurchschnitt", "bucketMonthly": "Monatsdurchschnitt", - "points7Label": "7 Pkt", - "points7Title": "Letzte 7 Messpunkte", - "points30Label": "30 Pkt", - "points30Title": "Letzte 30 Messpunkte", - "points90Label": "90 Pkt", - "points90Title": "Letzte 90 Messpunkte", - "pointsAllLabel": "Alle", - "pointsAllTitle": "Alle verfügbaren Messpunkte", + "days7Label": "7T", + "days7Title": "Letzte 7 Tage", + "days30Label": "30T", + "days30Title": "Letzte 30 Tage", + "days90Label": "90T", + "days90Title": "Letzte 90 Tage", + "daysAllLabel": "Alle", + "daysAllTitle": "Gesamter Zeitraum", "systolic": "Systolisch", "diastolic": "Diastolisch", "bodyWater": "Körperwasser", diff --git a/messages/en.json b/messages/en.json index 5f0ba98cf..1853215aa 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2198,14 +2198,14 @@ "noData": "No data in selected time range", "bucketWeekly": "Weekly avg", "bucketMonthly": "Monthly avg", - "points7Label": "7 pts", - "points7Title": "Last 7 data points", - "points30Label": "30 pts", - "points30Title": "Last 30 data points", - "points90Label": "90 pts", - "points90Title": "Last 90 data points", - "pointsAllLabel": "All", - "pointsAllTitle": "All available data points", + "days7Label": "7d", + "days7Title": "Last 7 days", + "days30Label": "30d", + "days30Title": "Last 30 days", + "days90Label": "90d", + "days90Title": "Last 90 days", + "daysAllLabel": "All", + "daysAllTitle": "All time", "systolic": "Systolic", "diastolic": "Diastolic", "bodyWater": "Body water", diff --git a/messages/es.json b/messages/es.json index c84ce503f..68910cac6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2198,14 +2198,14 @@ "noData": "Sin datos en el periodo seleccionado", "bucketWeekly": "Media semanal", "bucketMonthly": "Media mensual", - "points7Label": "7 ptos", - "points7Title": "Últimos 7 puntos", - "points30Label": "30 ptos", - "points30Title": "Últimos 30 puntos", - "points90Label": "90 ptos", - "points90Title": "Últimos 90 puntos", - "pointsAllLabel": "Todos", - "pointsAllTitle": "Todos los puntos disponibles", + "days7Label": "7 d", + "days7Title": "Últimos 7 días", + "days30Label": "30 d", + "days30Title": "Últimos 30 días", + "days90Label": "90 d", + "days90Title": "Últimos 90 días", + "daysAllLabel": "Todos", + "daysAllTitle": "Todo el período", "systolic": "Sistólica", "diastolic": "Diastólica", "bodyWater": "Agua corporal", diff --git a/messages/fr.json b/messages/fr.json index 7812cf02d..227a035c7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2198,14 +2198,14 @@ "noData": "Aucune donnée sur la période sélectionnée", "bucketWeekly": "Moyenne hebdomadaire", "bucketMonthly": "Moyenne mensuelle", - "points7Label": "7 pts", - "points7Title": "Derniers 7 points", - "points30Label": "30 pts", - "points30Title": "Derniers 30 points", - "points90Label": "90 pts", - "points90Title": "Derniers 90 points", - "pointsAllLabel": "Tous", - "pointsAllTitle": "Tous les points disponibles", + "days7Label": "7 j", + "days7Title": "7 derniers jours", + "days30Label": "30 j", + "days30Title": "30 derniers jours", + "days90Label": "90 j", + "days90Title": "90 derniers jours", + "daysAllLabel": "Tous", + "daysAllTitle": "Toute la période", "systolic": "Systolique", "diastolic": "Diastolique", "bodyWater": "Eau corporelle", diff --git a/messages/it.json b/messages/it.json index c0bc68d11..8dd448591 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2198,14 +2198,14 @@ "noData": "Nessun dato nel periodo selezionato", "bucketWeekly": "Media settimanale", "bucketMonthly": "Media mensile", - "points7Label": "7 pti", - "points7Title": "Ultimi 7 punti", - "points30Label": "30 pti", - "points30Title": "Ultimi 30 punti", - "points90Label": "90 pti", - "points90Title": "Ultimi 90 punti", - "pointsAllLabel": "Tutti", - "pointsAllTitle": "Tutti i punti disponibili", + "days7Label": "7 g", + "days7Title": "Ultimi 7 giorni", + "days30Label": "30 g", + "days30Title": "Ultimi 30 giorni", + "days90Label": "90 g", + "days90Title": "Ultimi 90 giorni", + "daysAllLabel": "Tutti", + "daysAllTitle": "Tutto il periodo", "systolic": "Sistolica", "diastolic": "Diastolica", "bodyWater": "Acqua corporea", diff --git a/messages/pl.json b/messages/pl.json index ae6be6cdb..5e1ea3491 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2198,14 +2198,14 @@ "noData": "Brak danych w wybranym okresie", "bucketWeekly": "Średnia tygodniowa", "bucketMonthly": "Średnia miesięczna", - "points7Label": "7 pkt", - "points7Title": "Ostatnie 7 punktów", - "points30Label": "30 pkt", - "points30Title": "Ostatnie 30 punktów", - "points90Label": "90 pkt", - "points90Title": "Ostatnie 90 punktów", - "pointsAllLabel": "Wszystkie", - "pointsAllTitle": "Wszystkie dostępne punkty", + "days7Label": "7 dni", + "days7Title": "Ostatnie 7 dni", + "days30Label": "30 dni", + "days30Title": "Ostatnie 30 dni", + "days90Label": "90 dni", + "days90Title": "Ostatnie 90 dni", + "daysAllLabel": "Wszystkie", + "daysAllTitle": "Cały okres", "systolic": "Skurczowe", "diastolic": "Rozkurczowe", "bodyWater": "Woda w organizmie", diff --git a/src/components/charts/__tests__/health-chart-a11y-label.test.tsx b/src/components/charts/__tests__/health-chart-a11y-label.test.tsx index d98a1eab5..d4d451eeb 100644 --- a/src/components/charts/__tests__/health-chart-a11y-label.test.tsx +++ b/src/components/charts/__tests__/health-chart-a11y-label.test.tsx @@ -11,11 +11,14 @@ import deMessages from "../../../../messages/de.json"; function buildData(): unknown[] { const out: Array<{ date: string; timestamp: number; PULSE: number }> = []; - const base = Date.UTC(2026, 0, 1, 12, 0, 0); + // Ten daily points ending half a day before now — the range tabs are + // calendar-day windows anchored on now (v1.37.29), so an absolute-dated + // fixture would age out of the default window and render the empty state. + const base = Date.now() - 12 * 3_600_000; for (let i = 0; i < 10; i++) { out.push({ - date: `2026-01-${String(i + 1).padStart(2, "0")}`, - timestamp: base + i * 86_400_000, + date: `p${i}`, + timestamp: base - (9 - i) * 86_400_000, PULSE: 60 + i, }); } diff --git a/src/components/charts/__tests__/health-chart-data-table.test.tsx b/src/components/charts/__tests__/health-chart-data-table.test.tsx index 68c3b3d2d..1b98d2849 100644 --- a/src/components/charts/__tests__/health-chart-data-table.test.tsx +++ b/src/components/charts/__tests__/health-chart-data-table.test.tsx @@ -11,12 +11,18 @@ import enMessages from "../../../../messages/en.json"; * table's rows can ever diverge, that is the failure this catches. */ -/** `spacingDays` apart, so a fixed point count can span a chosen range. */ +/** + * `spacingDays` apart, ENDING half a day before now. The range tabs are + * calendar-day windows anchored on now (v1.37.29), so an absolute-dated + * fixture would age out of the window and exercise the empty state + * instead of the table. The half-day offset keeps every point clear of + * the exact window-boundary instant. + */ function series(count: number, spacingDays = 1) { - const base = Date.UTC(2026, 0, 1, 12, 0, 0); + const base = Date.now() - 12 * 3_600_000; return Array.from({ length: count }, (_, i) => ({ date: `p${i}`, - timestamp: base + i * spacingDays * 86_400_000, + timestamp: base - (count - 1 - i) * spacingDays * 86_400_000, PULSE: 60 + i, })); } @@ -101,17 +107,23 @@ describe(" — data table", () => { }); it("honours the visible range rather than the whole fetched series", async () => { - // 40 points fetched, the default range tab draws the newest 30. + // 40 daily points fetched, the default range tab windows to the last + // 30 calendar days — the 30 points inside the window survive, the 10 + // older ones do not. const html = await renderChart(series(40), { showDataTable: true }); expect(rowCount(html)).toBe(30); expect(html).toContain("Data points (30)"); }); it("captions a bucketed range as an average instead of implying daily readings", async () => { - // 30 points four days apart span 116 days, so the chart buckets to ISO - // weeks and several points share a bucket. The rows are weekly means and - // the caption has to say so. - const html = await renderChart(series(30, 4), { showDataTable: true }); + // 30 points four days apart span 116 days on the All range, so the + // chart buckets to ISO weeks and several points share a bucket. The + // rows are weekly means and the caption has to say so. (The 7/30/90 + // tabs are day windows now, so only "All" can span past 90 days.) + const html = await renderChart(series(30, 4), { + showDataTable: true, + windowOverride: "allTime", + }); expect(html).toContain("One row per week of"); expect(html).toContain("the average of that week"); expect(html).not.toContain("One row per day of"); diff --git a/src/components/charts/__tests__/health-chart-empty-state-gate.test.tsx b/src/components/charts/__tests__/health-chart-empty-state-gate.test.tsx index cbffd9eb9..c41283a18 100644 --- a/src/components/charts/__tests__/health-chart-empty-state-gate.test.tsx +++ b/src/components/charts/__tests__/health-chart-empty-state-gate.test.tsx @@ -28,6 +28,18 @@ function buildData( })); } +/** + * ISO instant `agoDays` before now (noon-offset). The range tabs are + * calendar-day windows anchored on now (v1.37.29), so an absolute-dated + * fixture would age out of the default window and always paint the + * no-data card instead of the sparse states under test. + */ +function daysAgo(agoDays: number): string { + return new Date( + Date.now() - 12 * 3_600_000 - agoDays * 86_400_000, + ).toISOString(); +} + vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ isAuthenticated: true, @@ -71,8 +83,8 @@ describe(" — sparse-data render contract", () => { it("renders the chart (not a withholding card) with a sparse caption for two distinct days", async () => { const html = await renderChart( buildData([ - { measuredAt: "2026-05-19T09:00:00.000Z", value: 70 }, - { measuredAt: "2026-05-20T09:00:00.000Z", value: 72 }, + { measuredAt: daysAgo(1), value: 70 }, + { measuredAt: daysAgo(0), value: 72 }, ]), ); @@ -87,7 +99,7 @@ describe(" — sparse-data render contract", () => { it("renders the single marker + sparse caption for one day instead of a bare hint", async () => { const html = await renderChart( - buildData([{ measuredAt: "2026-05-20T09:00:00.000Z", value: 70 }]), + buildData([{ measuredAt: daysAgo(0), value: 70 }]), ); // A single point still renders (Recharts paints the marker) and the @@ -109,9 +121,9 @@ describe(" — sparse-data render contract", () => { it("omits the sparse caption once three or more distinct days exist", async () => { const html = await renderChart( buildData([ - { measuredAt: "2026-05-18T09:00:00.000Z", value: 68 }, - { measuredAt: "2026-05-19T09:00:00.000Z", value: 70 }, - { measuredAt: "2026-05-20T09:00:00.000Z", value: 72 }, + { measuredAt: daysAgo(2), value: 68 }, + { measuredAt: daysAgo(1), value: 70 }, + { measuredAt: daysAgo(0), value: 72 }, ]), ); diff --git a/src/components/charts/__tests__/mood-chart-aggregation.test.tsx b/src/components/charts/__tests__/mood-chart-aggregation.test.tsx index 4ff985d73..08a878312 100644 --- a/src/components/charts/__tests__/mood-chart-aggregation.test.tsx +++ b/src/components/charts/__tests__/mood-chart-aggregation.test.tsx @@ -152,9 +152,12 @@ describe(" bucket chip", () => { it("paints the weekly chip when the data spans > 90 days", async () => { // Defer the import so the vi.mock above takes effect. const { I18nProvider } = await import("@/lib/i18n/context"); + // The 7/30/90 tabs are calendar-day windows anchored on now + // (v1.37.29), so only the All range can hold a span past 90 days — + // which is the state the weekly chip exists for. const html = renderToStaticMarkup( - + , ); // The chip text comes from `charts.bucketWeekly` ("Weekly avg"). diff --git a/src/components/charts/__tests__/mood-chart-empty-state-gate.test.tsx b/src/components/charts/__tests__/mood-chart-empty-state-gate.test.tsx index 35bd7d700..70673c9de 100644 --- a/src/components/charts/__tests__/mood-chart-empty-state-gate.test.tsx +++ b/src/components/charts/__tests__/mood-chart-empty-state-gate.test.tsx @@ -23,6 +23,15 @@ vi.mock("@/hooks/use-auth", () => ({ }), })); +/** + * Day key `agoDays` before now. The range tabs are calendar-day windows + * anchored on now (v1.37.29), so absolute-dated entries would age out of + * the default window and both cases would collapse into the no-data copy. + */ +function dayKeyAgo(agoDays: number): string { + return new Date(Date.now() - agoDays * 86_400_000).toISOString().slice(0, 10); +} + describe(" — empty-state gate by raw count", () => { beforeEach(() => { vi.resetModules(); @@ -31,8 +40,8 @@ describe(" — empty-state gate by raw count", () => { it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => { const data = { entries: [ - { date: "2026-05-19", score: 4, samples: 25 }, - { date: "2026-05-20", score: 5, samples: 25 }, + { date: dayKeyAgo(1), score: 4, samples: 25 }, + { date: dayKeyAgo(0), score: 5, samples: 25 }, ], trend: null, }; @@ -65,7 +74,7 @@ describe(" — empty-state gate by raw count", () => { it("renders no-data copy when rawCount < 3", async () => { const data = { - entries: [{ date: "2026-05-20", score: 4, samples: 1 }], + entries: [{ date: dayKeyAgo(0), score: 4, samples: 1 }], trend: null, }; diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 3ea13abed..3528294f9 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -65,26 +65,33 @@ import { } from "@/lib/charts/window-stats"; import { shouldFireDataReady } from "@/lib/charts/data-ready-latch"; +// The range tabs select a CALENDAR-DAY window ending now — `days: 7` is +// "the last 7 days", not "the last 7 readings". The labels said "points" +// until v1.37.29 while the fetch below always treated the value as a day +// window; a user who weighs in twice a week picked "7 pts" and saw two. +// The persisted preference field keeps its historical name `rangePoints` +// (it crosses the chart-overlay-prefs wire), but its value has always +// been days. const TIME_RANGES_KEYS = [ { - labelKey: "charts.points7Label", - points: 7, - titleKey: "charts.points7Title", + labelKey: "charts.days7Label", + days: 7, + titleKey: "charts.days7Title", }, { - labelKey: "charts.points30Label", - points: 30, - titleKey: "charts.points30Title", + labelKey: "charts.days30Label", + days: 30, + titleKey: "charts.days30Title", }, { - labelKey: "charts.points90Label", - points: 90, - titleKey: "charts.points90Title", + labelKey: "charts.days90Label", + days: 90, + titleKey: "charts.days90Title", }, { - labelKey: "charts.pointsAllLabel", - points: 0, - titleKey: "charts.pointsAllTitle", + labelKey: "charts.daysAllLabel", + days: 0, + titleKey: "charts.daysAllTitle", }, ] as const; @@ -738,8 +745,8 @@ export function HealthChart({ const totalDays = windowDays + compareShift; const from = new Date(to.getTime() - totalDays * 86_400_000); // Use ISO strings so the cache key is stable across the day; the - // server treats `to` as the upper bound and the chart truncates to - // `rangePoints` on the client. + // server treats `to` as the upper bound and the chart re-applies the + // same day window client-side (see `visibleSlice`). return { from: from.toISOString(), to: to.toISOString(), @@ -1124,9 +1131,23 @@ export function HealthChart({ // by it, and the data table below the chart captions its rows with it — // a table that said "daily" over a weekly mean would be a lie, and // recomputing the decision separately is how that lie starts. + // + // v1.37.29 — the slice is a CALENDAR-DAY window, matching what the + // range tabs promise and what the fetch window requests. The previous + // `slice(-rangePoints)` was a no-op on the self-fetched path (the fetch + // is already bounded to the window) but silently switched to + // "last N readings" on the dashboard's preloaded ~30-day batch slice — + // the same tab meant two different things depending on the mount path. const visibleSlice = useMemo(() => { if (!data?.length) return null; - const sliced = rangePoints > 0 ? data.slice(-rangePoints) : [...data]; + const cutoff = new Date().getTime() - rangePoints * 86_400_000; + const sliced = + rangePoints > 0 + ? data.filter((point) => point.timestamp >= cutoff) + : [...data]; + // An empty window stays an empty slice (renders as "no data in this + // range") — returning null here would drop chartData back to the + // UNFILTERED series and quietly widen the window instead. const rangeDays = sliced.length < 2 ? 0 @@ -1226,7 +1247,7 @@ export function HealthChart({ // v1.12.8 — chart-reactive metric statistics. // // Compute the per-type Min / Max / Median / Mean over the data currently - // visible under the active range tab — i.e. the `rangePoints`-sliced, + // visible under the active range tab — i.e. the day-window-filtered, // bucketed `chartData` the chart already holds (no re-fetch). The result // recomputes whenever the range tab changes the visible slice, so the // `` a sub-page lifts the callback into always reflects the @@ -1330,16 +1351,12 @@ export function HealthChart({ ); }, [chartDataWithCompare, effectiveCompareBaseline, types]); - const activeBucket: ChartBucketType = useMemo(() => { - if (!data?.length) return "day"; - const sliced = rangePoints > 0 ? data.slice(-rangePoints) : data; - if (sliced.length < 2) return "day"; - const rangeDays = Math.round( - (sliced[sliced.length - 1].timestamp - sliced[0].timestamp) / - (24 * 60 * 60 * 1000), - ); - return pickBucket(rangeDays); - }, [data, rangePoints]); + // v1.37.29 — the header chip reads the SAME grain decision `chartData` + // and the data table use. This was a second memo re-deriving the bucket + // from a `slice(-rangePoints)` of its own — exactly the "recomputing + // the decision separately" drift the visibleSlice doc-comment warns + // about, and the two had already diverged on the windowing semantics. + const activeBucket: ChartBucketType = visibleSlice?.bucketType ?? "day"; const yDomain = useMemo<[number, number] | undefined>(() => { if (!chartDataWithCompare?.length) return undefined; @@ -1783,8 +1800,8 @@ export function HealthChart({ {TIME_RANGES_KEYS.map((r) => (