mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(charts): render sparse metric data instead of withholding it
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.
This commit is contained in:
1 parent
ebeb16daa3
commit
32ce6dfa18
9 files changed
+104
-162
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("<HealthChart> — empty-state gate by raw count", () => {
|
||||
async function renderChart(data: unknown[]): Promise<string> {
|
||||
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>,
|
||||
);
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
return html;
|
||||
}
|
||||
|
||||
describe("<HealthChart> — 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(
|
||||
<I18nProvider initialLocale="de">
|
||||
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<I18nProvider initialLocale="de">
|
||||
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
|
||||
</I18nProvider>,
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -10,19 +10,12 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
* The fix paints a "no data in this range" empty state via
|
||||
* `<ChartEmptyState>` 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("<HealthChart> — 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("<HealthChart> — 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");
|
||||
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
@@ -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.
|
||||
<ChartEmptyState
|
||||
title={t("charts.noDataInRangeTitle")}
|
||||
description={t("charts.noDataInRangeDescription")}
|
||||
/>
|
||||
) : (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 ? (
|
||||
<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 ? (
|
||||
@@ -1903,6 +1839,19 @@ export function HealthChart({
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{/* 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 ? (
|
||||
<p
|
||||
className="text-muted-foreground mt-2 text-center text-xs"
|
||||
data-slot="chart-sparse-caption"
|
||||
>
|
||||
{t("charts.sparseDataCaption")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user