From 32ce6dfa18f205ad20e2a7b604f1a9989d691e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 16:49:57 +0200 Subject: [PATCH] fix(charts): render sparse metric data instead of withholding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The metric chart withheld every reading behind a "more days needed" card until three distinct daily points existed, so a user with one or two days of real data (e.g. respiratory rate) saw a hint instead of their measurements. Render the available points for any non-empty window — a single marker for one day, a line for two — and add a subtle inline caption that more days fill out the trend. Only a genuinely empty window keeps the no-data card. The trend, moving-average, and comparison overlays already self-gate at two points, so relaxing the render gate cannot paint a misleading line from too little data. Drop the now-unused raw-count plumbing the old three-point copy split relied on. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../health-chart-empty-state-gate.test.tsx | 149 +++++++++--------- .../health-chart-no-data-in-range.test.tsx | 20 +-- src/components/charts/health-chart.tsx | 91 +++-------- 9 files changed, 104 insertions(+), 162 deletions(-) diff --git a/messages/de.json b/messages/de.json index 4d10544f1..581ad0cc8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1438,6 +1438,7 @@ "needMoreDistinctDaysDescription": "Erfasse an mehreren Tagen, um eine Trendlinie freizuschalten.", "noDataInRangeTitle": "Keine Daten in diesem Zeitraum", "noDataInRangeDescription": "Für den gewählten Bereich liegen keine Messungen vor. Wechsle den Zeitraum oder erfasse einen neuen Wert.", + "sparseDataCaption": "Mehr Messtage füllen den Trend.", "personalBaseline": "Dein Mittel", "deltaVsBaseline": "{delta} vs. dein Mittel", "deltaUnchanged": "entspricht deinem Mittel", diff --git a/messages/en.json b/messages/en.json index 6f7eee95c..79060ac87 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1438,6 +1438,7 @@ "needMoreDistinctDaysDescription": "Log on more days to unlock the trend line.", "noDataInRangeTitle": "No data in this range", "noDataInRangeDescription": "No measurements fall inside the selected range. Switch the range or log a new measurement.", + "sparseDataCaption": "More days will fill out the trend.", "personalBaseline": "Your normal", "deltaVsBaseline": "{delta} vs. your normal", "deltaUnchanged": "matches your normal", diff --git a/messages/es.json b/messages/es.json index 31fbcc8a1..4320e0efd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1438,6 +1438,7 @@ "needMoreDistinctDaysDescription": "Registra en más días para desbloquear la línea de tendencia.", "noDataInRangeTitle": "Sin datos en este rango", "noDataInRangeDescription": "No hay mediciones en el rango seleccionado. Cambia el rango o registra una nueva medición.", + "sparseDataCaption": "Más días completarán la tendencia.", "personalBaseline": "Tu media", "deltaVsBaseline": "{delta} vs. tu media", "deltaUnchanged": "coincide con tu media", diff --git a/messages/fr.json b/messages/fr.json index 969ed2511..8371ffcdd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1438,6 +1438,7 @@ "needMoreDistinctDaysDescription": "Saisis sur plusieurs jours pour débloquer la courbe de tendance.", "noDataInRangeTitle": "Aucune donnée sur cette période", "noDataInRangeDescription": "Aucune mesure ne tombe dans la plage sélectionnée. Change de plage ou saisis une nouvelle mesure.", + "sparseDataCaption": "Davantage de jours étofferont la tendance.", "personalBaseline": "Ta moyenne", "deltaVsBaseline": "{delta} vs. ta moyenne", "deltaUnchanged": "correspond à ta moyenne", diff --git a/messages/it.json b/messages/it.json index 55e954b18..6cbbd13cd 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1438,6 +1438,7 @@ "needMoreDistinctDaysDescription": "Registra in più giorni per sbloccare la linea di tendenza.", "noDataInRangeTitle": "Nessun dato in questo intervallo", "noDataInRangeDescription": "Nessuna misurazione rientra nell'intervallo selezionato. Cambia intervallo o registra una nuova misurazione.", + "sparseDataCaption": "Più giorni completeranno il trend.", "personalBaseline": "La tua media", "deltaVsBaseline": "{delta} vs. la tua media", "deltaUnchanged": "corrisponde alla tua media", diff --git a/messages/pl.json b/messages/pl.json index 4cf5a126a..bc818c0bc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1438,6 +1438,7 @@ "needMoreDistinctDaysDescription": "Rejestruj w więcej dni, aby odblokować linię trendu.", "noDataInRangeTitle": "Brak danych w tym zakresie", "noDataInRangeDescription": "Żadne pomiary nie mieszczą się w wybranym zakresie. Zmień zakres lub dodaj nowy pomiar.", + "sparseDataCaption": "Więcej dni dopełni trend.", "personalBaseline": "Twoja średnia", "deltaVsBaseline": "{delta} vs. twoja średnia", "deltaUnchanged": "odpowiada twojej średniej", 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 8bf3a65ef..847222896 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 @@ -2,38 +2,26 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; /** - * v1.4.43 W2-CHART-GATE — pin the empty-state copy split. + * Sparse-data render contract. * - * Pre-v1.4.43 the chart painted "Erfasse mehr Messungen, um die - * Trendlinie freizuschalten" whenever `chartData.length < 3` — but - * `chartData` is daily-aggregated, so a user with many measurements - * on < 3 distinct days saw the misleading "log more measurements" - * hint despite having logged plenty. The gate now keys on the raw - * measurement count surfaced via the `rawCount` property the - * queryFn stashes on its return array. When `rawCount >= 3` the - * "need more days" copy paints; otherwise the legacy "log more" - * copy stays. + * The metric chart no longer withholds real data behind a "more days + * needed" card. Any non-empty window paints the available points — a + * single marker for one day, a line for two — and adds a subtle inline + * caption (`chart-sparse-caption`) when fewer than three daily points + * exist so the user understands more days fill out the trend. Only a + * genuinely empty window (zero daily points) paints the no-data card. */ function buildData( rows: Array<{ measuredAt: string; value: number; count?: number }>, - rawCount: number, ): unknown[] { - // Re-use the same array-with-stashed-property shape the real - // queryFn produces. The chart's `useMemo(() => …, [data])` reads - // `(data as ChartDataPoint[] & { rawCount?: number }).rawCount`. - const points = rows.map((r) => ({ + // The queryFn returns daily-aggregated points; mirror that shape so + // the chart's `useMemo(() => …, [data])` derives the same chartData. + return rows.map((r) => ({ date: r.measuredAt.slice(0, 10), timestamp: new Date(r.measuredAt).getTime(), PULSE: r.value, })); - Object.defineProperty(points, "rawCount", { - value: rawCount, - enumerable: false, - configurable: false, - writable: false, - }); - return points; } vi.mock("@/hooks/use-auth", () => ({ @@ -44,78 +32,83 @@ vi.mock("@/hooks/use-auth", () => ({ }), })); -describe(" — empty-state gate by raw count", () => { +async function renderChart(data: unknown[]): Promise { + vi.doMock("@tanstack/react-query", () => ({ + useQuery: () => ({ data, isLoading: false }), + useQueryClient: () => ({ + cancelQueries: () => Promise.resolve(), + getQueryData: () => undefined, + setQueryData: () => undefined, + invalidateQueries: () => Promise.resolve(), + }), + useMutation: () => ({ mutate: () => undefined, isPending: false }), + })); + + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + const html = renderToStaticMarkup( + + + , + ); + vi.doUnmock("@tanstack/react-query"); + return html; +} + +describe(" — sparse-data render contract", () => { beforeEach(() => { vi.resetModules(); }); - - it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => { - // Two distinct days with 50 raw rows total — the daily-aggregated - // chartData collapses to 2 points but the user logged plenty. - const data = buildData( - [ + 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 }, - ], - 50, + ]), ); - vi.doMock("@tanstack/react-query", () => ({ - useQuery: () => ({ data, isLoading: false }), - useQueryClient: () => ({ - cancelQueries: () => Promise.resolve(), - getQueryData: () => undefined, - setQueryData: () => undefined, - invalidateQueries: () => Promise.resolve(), - }), - useMutation: () => ({ mutate: () => undefined, isPending: false }), - })); - - const { I18nProvider } = await import("@/lib/i18n/context"); - const { HealthChart } = await import("../health-chart"); - - const html = renderToStaticMarkup( - - - , - ); - - expect(html).toContain("Mehr Messtage erforderlich"); + // The chart paints the available points + a subtle caption … + expect(html).toContain('data-slot="chart-sparse-caption"'); + expect(html).toContain("Mehr Messtage füllen den Trend."); + // … and does NOT fall back to the old withholding empty-state copy. + expect(html).not.toContain('data-slot="chart-empty-state"'); + expect(html).not.toContain("Mehr Messtage erforderlich"); expect(html).not.toContain("Erfasse mindestens 3 Einträge"); - - vi.doUnmock("@tanstack/react-query"); }); - it("renders no-data copy when rawCount < 3", async () => { - const data = buildData( - [{ measuredAt: "2026-05-20T09:00:00.000Z", value: 70 }], - 1, + 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 }]), ); - vi.doMock("@tanstack/react-query", () => ({ - useQuery: () => ({ data, isLoading: false }), - useQueryClient: () => ({ - cancelQueries: () => Promise.resolve(), - getQueryData: () => undefined, - setQueryData: () => undefined, - invalidateQueries: () => Promise.resolve(), - }), - useMutation: () => ({ mutate: () => undefined, isPending: false }), - })); + // A single point still renders (Recharts paints the marker) and the + // caption stays — no withholding card for one real reading. + expect(html).toContain('data-slot="chart-sparse-caption"'); + expect(html).toContain("Mehr Messtage füllen den Trend."); + expect(html).not.toContain('data-slot="chart-empty-state"'); + expect(html).not.toContain("Erfasse mindestens 3 Einträge"); + }); - const { I18nProvider } = await import("@/lib/i18n/context"); - const { HealthChart } = await import("../health-chart"); + it("renders the no-data card with no sparse caption when the window is empty", async () => { + const html = await renderChart(buildData([])); - const html = renderToStaticMarkup( - - - , + expect(html).toContain('data-slot="chart-empty-state"'); + expect(html).toContain("Für den gewählten Bereich liegen keine Messungen"); + expect(html).not.toContain('data-slot="chart-sparse-caption"'); + }); + + 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 }, + ]), ); - expect(html).toContain("Erfasse mindestens 3 Einträge"); - expect(html).not.toContain("Mehr Messtage erforderlich"); - - vi.doUnmock("@tanstack/react-query"); + expect(html).not.toContain('data-slot="chart-sparse-caption"'); + expect(html).not.toContain('data-slot="chart-empty-state"'); }); }); diff --git a/src/components/charts/__tests__/health-chart-no-data-in-range.test.tsx b/src/components/charts/__tests__/health-chart-no-data-in-range.test.tsx index af2a6d546..ab867905c 100644 --- a/src/components/charts/__tests__/health-chart-no-data-in-range.test.tsx +++ b/src/components/charts/__tests__/health-chart-no-data-in-range.test.tsx @@ -10,19 +10,12 @@ import { renderToStaticMarkup } from "react-dom/server"; * The fix paints a "no data in this range" empty state via * `` so the dashboard composition stays intact and * the user knows the chart loaded but the selected window holds no - * measurements. The copy is distinct from the < 3-points "Mehr - * Messtage erforderlich" branch so the two situations don't blur. + * measurements. The copy is distinct from the sparse-data caption + * (one / two daily points) so the two situations don't blur. */ -function buildData(rawCount: number): unknown[] { - const points: unknown[] = []; - Object.defineProperty(points, "rawCount", { - value: rawCount, - enumerable: false, - configurable: false, - writable: false, - }); - return points; +function buildData(): unknown[] { + return []; } vi.mock("@/hooks/use-auth", () => ({ @@ -39,7 +32,7 @@ describe(" — empty-window state (W11-M6)", () => { }); it("renders the no-data-in-range copy when data resolves to an empty array", async () => { - const data = buildData(0); + const data = buildData(); vi.doMock("@tanstack/react-query", () => ({ useQuery: () => ({ data, isLoading: false }), @@ -67,8 +60,9 @@ describe(" — empty-window state (W11-M6)", () => { // dashboard isn't missing a tile. The pre-fix `return null` would // have stripped this entirely. expect(html).toContain("Pulse"); - // The < 3-points branch must NOT paint here — empty-window and + // The sparse-data caption must NOT paint here — empty-window and // sparse-data are different situations with distinct copy. + expect(html).not.toContain('data-slot="chart-sparse-caption"'); expect(html).not.toContain("Mehr Messtage erforderlich"); expect(html).not.toContain("Erfasse mindestens 3 Einträge"); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index d5c47b77e..2158ea03a 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -622,14 +622,6 @@ export function HealthChart({ } >(); - // v1.4.43 W2-CHART-GATE — accumulate the raw measurement count - // across every fetched type so the empty-state copy can - // distinguish "user logged < 3 measurements" from "user logged - // many measurements but on < 3 distinct days". The chartData - // length collapses to daily buckets and would otherwise paint - // the "log more" hint even when the user already logged 50 - // entries on 2 days. - let rawMeasurementCount = 0; async function fetchMeasurementsByType(type: string) { const typeParams = new URLSearchParams(); typeParams.set("type", type); @@ -684,12 +676,6 @@ export function HealthChart({ continue; } - // v1.4.43 W2-CHART-GATE — rollup / server-aggregated rows - // carry the underlying `count`; raw rows do not, so each - // counts as one. Sum across types since the gate is the - // total user-logged measurements in the window. - rawMeasurementCount += measurement.count ?? 1; - const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter); const bucket = dailyAggregates.get(dayKey) ?? { timestamp: dayKeyToTimestamp(dayKey), @@ -757,30 +743,11 @@ export function HealthChart({ }) .sort((a, b) => a.timestamp - b.timestamp); - // v1.4.43 W2-CHART-GATE — stash the raw measurement count on - // the returned array as a non-enumerable property so the gate - // logic can distinguish "few measurements" from "few days" in - // the empty-state copy. Non-enumerable keeps the array shape - // intact for every downstream consumer that iterates / spreads. - Object.defineProperty(allData, "rawCount", { - value: rawMeasurementCount, - enumerable: false, - configurable: false, - writable: false, - }); - return allData; }, enabled: isAuthenticated, }); - // v1.4.43 W2-CHART-GATE — surface the stashed raw count. - const rawCount = useMemo(() => { - if (!data) return 0; - const value = (data as ChartDataPoint[] & { rawCount?: number }).rawCount; - return typeof value === "number" ? value : 0; - }, [data]); - const chartData = useMemo(() => { if (!data?.length) return data; @@ -1370,48 +1337,17 @@ export function HealthChart({ ) : !chartData?.length ? ( // v1.4.43 W11-M6 — empty-window state. // - // Pre-fix the chart silently `return null`ed when `chartData` - // resolved empty (after-load, no points in the selected - // range). Paired with the < 3-points hint, the user's - // five visible chart states were: - // - // - skeleton (loading) - // - 1-2 daily points / many raw → "Mehr Messtage erforderlich" - // - 1-2 daily points / < 3 raw → "Erfasse mindestens 3 Einträge" - // - ≥ 3 daily points → line chart - // - 0 daily points → NOTHING - // - // The "nothing" branch read as a broken widget on the - // dashboard. Paint a distinct empty state explaining the - // selected range is empty so the user reaches for the range - // tabs or the quick-add instead of suspecting a bug. + // The chart only withholds rendering when the selected range + // holds zero daily points. Any real data — even a single day — + // renders the points below; a sparse-data caption (rather than + // a withholding card) explains that more days fill out the + // trend. The genuinely-empty window paints a distinct empty + // state so the user reaches for the range tabs or the quick-add + // instead of suspecting a broken widget. - ) : (chartData?.length ?? 0) < 3 ? ( - // v1.4.16 B1a — sparse-data placeholder. <3 daily points is too - // few to render a meaningful trend; paint a friendly hint - // instead so the dashboard doesn't look broken. - // - // v1.4.43 W2-CHART-GATE — split the copy on the raw measurement - // count. A user with 50 BP readings on 2 calendar days has - // `chartData.length = 2` (daily-aggregated) but `rawCount = 50`, - // so the legacy "log more measurements" hint was misleading. - // When the user has logged enough raw measurements but only - // hit < 3 distinct days, paint the "need more days" copy - // instead so the next action is clear. - rawCount >= 3 ? ( - - ) : ( - - ) ) : (
{visibleBands.length > 0 ? ( @@ -1903,6 +1839,19 @@ export function HealthChart({
+ {/* Sparse-data caption. With fewer than three daily points the + chart still paints every reading (a single marker for one + day, a line for two), and this subtle note sets the + expectation that more days fill out the trend — rather than + withholding the data behind a placeholder card. */} + {!mini && (chartData?.length ?? 0) < 3 ? ( +

+ {t("charts.sparseDataCaption")} +

+ ) : null} )}