diff --git a/CHANGELOG.md b/CHANGELOG.md index 880236fd0..8b444960f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.37.29] — 2026-08-26 + +The chart range selector said points and meant days. Someone who weighs in twice a week picked "7 pts", saw two, and reasonably concluded the chart was broken. + +### Fixed + +- The range tabs now say what they do: 7d / 30d / 90d / All, in every locale. The selector has always fetched a calendar-day window; only the labels claimed a point count. Anyone measuring less than daily saw fewer points than the tab promised and had no way to know the tab was the lying part. +- The "All" view no longer serves monthly means under a weekly-average caption. The server picked the aggregation tier from the requested window, and "All" always requests ten years, so every record came back in monthly buckets no matter how short its history, while the chart captioned the series from the real span. A four-month record showed four points labelled "Weekly avg". The tier now follows the span the data actually covers, and a test pins the server tier against the caption ladder for every span up to ten years so the two cannot drift apart again. +- The same tab no longer means two different things depending on how the chart mounted. On the dashboard's preloaded data the old code showed "the last N readings" reaching arbitrarily far back; on a directly opened chart it showed a day window. Both paths now filter to the same calendar-day window, which also keeps comparison-overlay shadow data from leaking into the visible range on sparse records. +- The mood chart windows by days like every other chart. It used to slice the last N logged days out of the whole history, so its "7" could span weeks while the weight chart's "7" meant a week. +- The aggregation chip in the chart header reads the same grain decision the chart and its data table use, instead of re-deriving it separately. + ## [1.37.28] — 2026-08-24 Eleven parts of a shared record were selectable and invisible. Ticking one and sending the link showed the recipient nothing, while the same link's PDF download carried it. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ebba70075..c82740bb4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.37.28 + version: 1.37.29 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 5311a50d8..37b124f6d 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -60,11 +60,17 @@ const A11Y_WORKOUT_ID = "a11y-workout"; const A11Y_CONVERSATION_ID = "a11y-conversation"; const A11Y_DOCUMENT_ID = "a11y-document"; +// Ten daily rows ending half a day before now. The chart range tabs are +// calendar-day windows anchored on now (v1.37.29), so absolute-dated rows +// age out of the default window — the weight sub-page would render its +// empty state and the `.recharts-wrapper` painted gate would never fire. const fixedMeasurementRows = Array.from({ length: 10 }, (_, index) => ({ id: `a11y-measurement-${index}`, type: "WEIGHT", value: 78.5 + (index % 3) - 1, - measuredAt: `2026-07-${String(20 - index).padStart(2, "0")}T08:00:00.000Z`, + measuredAt: new Date( + Date.now() - 12 * 3_600_000 - index * 86_400_000, + ).toISOString(), source: "MANUAL", notes: null, })); diff --git a/e2e/charts-mobile.spec.ts b/e2e/charts-mobile.spec.ts index 3e933b4d4..f9c5cabd3 100644 --- a/e2e/charts-mobile.spec.ts +++ b/e2e/charts-mobile.spec.ts @@ -8,8 +8,8 @@ import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot"; * * Two assertions per chart visible in the dashboard's initial paint: * - * 1. Header layout integrity — the range tabs (`7 pts / 30 pts / - * 90 pts / All`) and the cog dropdown sit on a SINGLE horizontal + * 1. Header layout integrity — the range tabs (`7d / 30d / + * 90d / All`) and the cog dropdown sit on a SINGLE horizontal * row inside the chart card. Pre-fix the bucket-aggregation chip * ("Weekly avg" / "Monthly avg") + comparison caption pushed the * tabs onto a second row on Pixel 5 (and 3-4 rows on Galaxy Fold 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/package.json b/package.json index ae18d1e36..a525395bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.28", + "version": "1.37.29", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index e9fe10cdf..05a7ea72b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.37.28"; + /* @sw-version-fallback */ "v1.37.29"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` 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) => (