fix(charts): split health-chart empty-state copy on raw measurement count

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 21:45:32 +02:00
1 parent f4eb783300
commit f68b8c422a
2 files changed
+180 -4

No files matched your search

@@ -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("<HealthChart> — 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(
<I18nProvider initialLocale="de">
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
</I18nProvider>,
);
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(
<I18nProvider initialLocale="de">
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
</I18nProvider>,
);
expect(html).toContain("Erfasse mindestens 3 Einträge");
expect(html).not.toContain("Mehr Messtage erforderlich");
vi.doUnmock("@tanstack/react-query");
});
});
+59 -4
View File
@@ -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<number>(() => {
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.
<ChartEmptyState
title={t("charts.emptyStateTitle")}
description={t("charts.emptyStateDescription")}
/>
//
// 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 ? (
<ChartEmptyState
title={t("charts.needMoreDistinctDaysTitle")}
description={t("charts.needMoreDistinctDaysDescription")}
/>
) : (
<ChartEmptyState
title={t("charts.emptyStateTitle")}
description={t("charts.emptyStateDescription")}
/>
)
) : (
<div className={`relative ${chartHeightClass}`}>
{visibleBands.length > 0 ? (