From f68b8c422a230f25e51f1b3b2f4ca82fd8a2cb69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 21:31:33 +0200 Subject: [PATCH] fix(charts): split health-chart empty-state copy on raw measurement count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty-state caption was gated on `chartData.length < 3`, but chartData is daily-aggregated. A user with 50 blood-pressure readings on 2 distinct days saw "log at least 3 entries" despite already logging plenty — only the day-spread was sparse. Track the raw measurement count during the per-type fetch (using the rollup endpoint's `count` field when available, falling back to 1 per row on the raw path) and stash it as a non-enumerable property on the returned array. The render gate now paints "More distinct days needed" when chartData is short but the raw count is already ≥ 3, and the legacy "log more" copy stays for the genuinely sparse case. Pin both branches with an SSR test that mocks the queryFn return. --- .../health-chart-empty-state-gate.test.tsx | 121 ++++++++++++++++++ src/components/charts/health-chart.tsx | 63 ++++++++- 2 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 src/components/charts/__tests__/health-chart-empty-state-gate.test.tsx 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 new file mode 100644 index 000000000..8bf3a65ef --- /dev/null +++ b/src/components/charts/__tests__/health-chart-empty-state-gate.test.tsx @@ -0,0 +1,121 @@ +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. + * + * 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. + */ + +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) => ({ + 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", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: null, + isLoading: false, + }), +})); + +describe(" — empty-state gate by raw count", () => { + 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( + [ + { 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"); + 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, + ); + + 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("Erfasse mindestens 3 Einträge"); + expect(html).not.toContain("Mehr Messtage erforderlich"); + + vi.doUnmock("@tanstack/react-query"); + }); +}); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 18590fe20..514b20911 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -185,6 +185,13 @@ interface ChartDataPoint { interface MeasurementApiRow { measuredAt: string; value: number; + // v1.4.43 W2-CHART-GATE — the rollup / daily-aggregate paths return + // the underlying raw-row count per bucket. Used downstream to gate + // the "more days needed" empty-state copy on the actual measurement + // count rather than the number of distinct calendar days. Optional + // because the short-window raw-row path does not populate it (each + // row already represents one measurement, count = 1). + count?: number; } interface VisibleTargetZone { @@ -576,6 +583,14 @@ 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); @@ -625,6 +640,12 @@ 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), @@ -664,11 +685,30 @@ 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; @@ -1252,10 +1292,25 @@ export function HealthChart({ // 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 ? (