mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user