From 0823a4e22203db5d8cdff365c81494db29549cb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 26 Aug 2026 17:55:43 +0200 Subject: [PATCH 1/5] Refine the tiered rollup read by the actual data span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tier for a chart series keyed off the requested window width. The All range always requests ~3650 days, so every account got MONTH buckets regardless of how much history exists — a record spanning four months came back as four monthly means, and the chart captioned them from the real span as a weekly average. The reader now re-reads at the tier the returned rows' span calls for; a coverage miss on the finer tier keeps the coarser rows. The ladder comment claimed to mirror the client's pickBucket thresholds and misquoted them. The two ladders are deliberately not identical; a test now pins the direction that keeps the caption honest (server never coarser than the client captions) for every span up to ten years. --- .../__tests__/measurement-read-wmy.test.ts | 124 ++++++++++++++++++ src/lib/rollups/measurement-read-wmy.ts | 65 +++++++-- 2 files changed, 177 insertions(+), 12 deletions(-) diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index 2ea969fc0..39d0e3f0d 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -43,10 +43,12 @@ vi.mock("@/lib/db", () => ({ import { aggregateWmyBuckets, + pickRollupGranularityForWindow, readBestGranularityRollups, readTieredRollupSeries, type RollupBucketRow, } from "../measurement-read-wmy"; +import { pickBucket } from "@/lib/charts/bucket-time-series"; const { findMany } = mocks; @@ -70,6 +72,12 @@ function bucket( beforeEach(() => { findMany.mockReset(); + // Default to "no coverage" for any read a test did not queue explicitly — + // the v1.37.29 span-refinement probe in `readTieredRollupSeries` issues an + // extra read when a fixture's rows span less than the requested window, + // and an unqueued vi.fn() would resolve `undefined` and crash the reader + // instead of modelling an unminted tier. + findMany.mockResolvedValue([]); mocks.userFindUnique.mockReset(); mocks.userFindUnique.mockResolvedValue({ sourcePriorityJson: null }); }); @@ -454,4 +462,120 @@ describe("readTieredRollupSeries", () => { expect(result?.rows[0].minValue).toBe(78); expect(result?.rows[0].maxValue).toBe(85); }); + + // v1.37.29 — the tier keys off the ACTUAL data span, not the requested + // window width. The "All" tab always requests ~3650 days, so pre-fix a + // record whose history spans four months came back as four MONTH means + // while the chart captioned them from the real span — "weekly average" + // over monthly buckets. + describe("span refinement", () => { + it("re-reads at the tier the real span calls for (4 months of data on an All request → DAY)", async () => { + const months = [ + bucket("2026-02-01T00:00:00.000Z"), + bucket("2026-03-01T00:00:00.000Z"), + bucket("2026-04-01T00:00:00.000Z"), + bucket("2026-05-01T00:00:00.000Z"), + ]; + const days = [ + bucket("2026-02-03T00:00:00.000Z"), + bucket("2026-03-14T00:00:00.000Z"), + bucket("2026-04-09T00:00:00.000Z"), + bucket("2026-05-28T00:00:00.000Z"), + ]; + findMany.mockResolvedValueOnce(months).mockResolvedValueOnce(days); + + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + ...win(3650), + }); + + // Span of the MONTH rows ≈ 89 days → the display ladder wants DAY, + // and the refinement jumps straight there (no WEEK stop-over). + expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); + expect(findMany.mock.calls[1][0].where.granularity).toBe("DAY"); + expect(findMany).toHaveBeenCalledTimes(2); + expect(result?.granularity).toBe("DAY"); + expect(result?.rows).toHaveLength(4); + expect(result?.rows[0].measuredAt).toBe("2026-02-03T00:00:00.000Z"); + }); + + it("refines to WEEK for a span between one and two years", async () => { + const months = [ + bucket("2025-01-01T00:00:00.000Z"), + bucket("2026-05-01T00:00:00.000Z"), + ]; + const weeks = [ + bucket("2024-12-30T00:00:00.000Z"), + bucket("2026-04-27T00:00:00.000Z"), + ]; + findMany.mockResolvedValueOnce(months).mockResolvedValueOnce(weeks); + + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + ...win(3650), + }); + + expect(findMany.mock.calls[1][0].where.granularity).toBe("WEEK"); + expect(result?.granularity).toBe("WEEK"); + }); + + it("keeps the coarser rows when the finer tier has no coverage", async () => { + const months = [ + bucket("2026-01-01T00:00:00.000Z"), + bucket("2026-05-01T00:00:00.000Z"), + ]; + findMany.mockResolvedValueOnce(months).mockResolvedValueOnce([]); + + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + ...win(3650), + }); + + expect(result?.granularity).toBe("MONTH"); + expect(result?.rows).toHaveLength(2); + }); + + it("does not probe when the rows already span the requested tier", async () => { + const months = [ + bucket("2018-01-01T00:00:00.000Z"), + bucket("2026-05-01T00:00:00.000Z"), + ]; + findMany.mockResolvedValueOnce(months); + + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + ...win(3650), + }); + + expect(findMany).toHaveBeenCalledTimes(1); + expect(result?.granularity).toBe("MONTH"); + }); + }); +}); + +// v1.37.29 — the server tier ladder and the client caption ladder +// (`pickBucket`) are DIFFERENT functions with different thresholds, and +// their divergence is exactly what shipped monthly means under a +// "weekly average" chip. The invariant that keeps the caption honest is +// directional: for any data span, the tier the server serves is never +// COARSER than the bucket the client captions — the client may fold +// finer rows up to its caption, but it cannot split coarser ones. +describe("server tier vs client caption ladder", () => { + const SERVER_COARSENESS = { DAY: 0, WEEK: 1, MONTH: 2, YEAR: 3 } as const; + const CLIENT_COARSENESS = { day: 0, week: 1, month: 2 } as const; + + it("never serves coarser than the client captions, for every span up to ten years", () => { + for (let span = 1; span <= 3650; span++) { + const server = pickRollupGranularityForWindow(span); + const client = pickBucket(span); + expect( + SERVER_COARSENESS[server], + `span ${span}d: server ${server} vs client caption ${client}`, + ).toBeLessThanOrEqual(CLIENT_COARSENESS[client]); + } + }); }); diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index 779adf443..5121ce427 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -303,20 +303,28 @@ export interface TieredSeriesRow { } /** - * Pick the rollup granularity that matches the chart's own client-side - * `bucketTimeSeries` downsampler (`src/lib/charts/bucket-time-series.ts`): + * Pick the rollup granularity for a chart window of `windowDays`: * - * - > 730 days → MONTH (the chart renders month points) - * - 366–730 → WEEK (the chart renders week points) + * - > 730 days → MONTH + * - 366–730 → WEEK * - ≤ 365 → DAY (daily resolution; the DAY cap covers it) * - * Mirroring the display tier means the server returns exactly the - * resolution the chart will paint — no finer (which would truncate at the - * DAY cap) and no coarser (which would drop visible detail). The finer - * fallback in `readTieredRollupSeries` still rescues a tenant whose - * coarse buckets the worker has not minted yet. + * This ladder must stay COMPATIBLE with the chart's client-side + * `pickBucket` (`src/lib/charts/bucket-time-series.ts`), which captions + * the rendered series from the visible data span (day ≤ 90, week 91–730, + * month > 730): for any span the tier chosen here is never COARSER than + * the caption the client will print, so a "Wochendurchschnitt" chip + * always sits over points the client actually folded into weeks. The + * ladders are not identical — this one may be finer (DAY rows for a + * 91–365 d span; the client folds them into the weeks it captions) — + * and a test pins the never-coarser direction across every span. + * + * The finer fallback in `readTieredRollupSeries` still rescues a tenant + * whose coarse buckets the worker has not minted yet. */ -function pickRollupGranularityForWindow(windowDays: number): RollupGranularity { +export function pickRollupGranularityForWindow( + windowDays: number, +): RollupGranularity { if (windowDays > 730) return "MONTH"; if (windowDays > 365) return "WEEK"; return "DAY"; @@ -393,8 +401,8 @@ export async function readTieredRollupSeries(opts: { // drop detail the chart can render). Stop at the first tier with coverage. const targetIdx = TIER_ORDER.indexOf(target); for (let i = targetIdx; i >= 0; i--) { - const granularity = TIER_ORDER[i]; - const rows = await readGranularity( + let granularity = TIER_ORDER[i]; + let rows = await readGranularity( userId, type, granularity, @@ -403,6 +411,39 @@ export async function readTieredRollupSeries(opts: { priority, ); if (rows && rows.length > 0) { + // Refine the tier by the ACTUAL data span, not the requested window. + // The "Alle" tab always requests ~3650 days, so keying the tier off + // the request width alone handed EVERY account MONTH buckets — a + // record whose history spans four months came back as four monthly + // means while the client (which captions from the real span) labelled + // them "weekly average". Re-reading at the tier the real span calls + // for returns the resolution the chart will actually caption. A + // coverage miss on the finer tier keeps the coarser rows — finer + // tiers are the base tiers the workers mint first, so in practice + // this probe only fires when the refinement is genuinely available. + // The walk is strictly finer-only, so it terminates. + for (;;) { + const spanDays = Math.ceil( + (rows[rows.length - 1].bucketStart.getTime() - + rows[0].bucketStart.getTime()) / + 86_400_000, + ); + const refined = pickRollupGranularityForWindow(Math.max(1, spanDays)); + if (TIER_ORDER.indexOf(refined) >= TIER_ORDER.indexOf(granularity)) { + break; + } + const finerRows = await readGranularity( + userId, + type, + refined, + from, + to, + priority, + ); + if (!finerRows || finerRows.length === 0) break; + granularity = refined; + rows = finerRows; + } const useSum = CUMULATIVE_HK_TYPES.has(type); annotate({ action: { name: "measurement.list" }, From 240c15ff15f4880e234d0767cb1c5a3dc4918805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 26 Aug 2026 17:55:56 +0200 Subject: [PATCH 2/5] Label the chart range tabs as the day windows they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The range selector said points — 7 pts, Last 7 data points — while the fetch has always treated the value as a calendar-day window. Someone who weighs in twice a week picked 7 pts and saw two, and read that as a broken chart; the labels were the broken part. The tabs now say 7d / 30d / 90d / All across all six locales. Behaviour is unified on the same reading everywhere. The measurement chart's visible slice becomes a real day-window filter — the old slice(-N) was a no-op on the self-fetched path but silently meant last N readings on the dashboard's preloaded batch slice, so the same tab meant two different things depending on the mount path. The mood chart sliced the last N logged days out of the whole history and now windows by days like its siblings. The header chip reads the same grain decision the chart and the data table use instead of re-deriving it in a second memo. Component fixtures move to now-anchored dates: the windows are anchored on now, so absolute-dated fixtures age out of the window and would have exercised only the empty states. The span-dependent cases (weekly chip, bucketed-caption table) mount the All range, which is now the only range that can span past 90 days. --- messages/de.json | 16 ++-- messages/en.json | 16 ++-- messages/es.json | 16 ++-- messages/fr.json | 16 ++-- messages/it.json | 16 ++-- messages/pl.json | 16 ++-- .../health-chart-a11y-label.test.tsx | 9 ++- .../health-chart-data-table.test.tsx | 28 +++++-- .../health-chart-empty-state-gate.test.tsx | 24 ++++-- .../__tests__/mood-chart-aggregation.test.tsx | 5 +- .../mood-chart-empty-state-gate.test.tsx | 15 +++- src/components/charts/health-chart.tsx | 77 +++++++++++-------- src/components/charts/mini-window.ts | 23 +++--- src/components/charts/mood-chart.tsx | 53 ++++++++----- 14 files changed, 203 insertions(+), 127 deletions(-) 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) => (