fix(charts): paint empty-window state instead of erasing the card

`health-chart.tsx` returned `null` when the loaded payload was
empty in the selected range, which stripped the entire widget
(header + tabs + chart). On the dashboard the user saw a missing
tile with no explanation. Keep the card chrome mounted and paint a
`<ChartEmptyState>` with distinct copy from the < 3-points
"Mehr Messtage erforderlich" branch so the two situations don't
blur. Adds `charts.noDataInRange*` strings across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 22:09:12 +02:00
1 parent d02deb1769
commit 54fbbf8fc8
8 files changed
+120 -2

No files matched your search

+2
View File
@@ -862,6 +862,8 @@
"emptyStateDescription": "Erfasse mindestens 3 Einträge, um die Trendlinie freizuschalten.",
"needMoreDistinctDaysTitle": "Mehr Messtage erforderlich",
"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.",
"personalBaseline": "Dein Mittel",
"deltaVsBaseline": "{delta} vs. dein Mittel",
"deltaUnchanged": "entspricht deinem Mittel",
+2
View File
@@ -862,6 +862,8 @@
"emptyStateDescription": "Log at least 3 entries to unlock the trend line.",
"needMoreDistinctDaysTitle": "More distinct days needed",
"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.",
"personalBaseline": "Your normal",
"deltaVsBaseline": "{delta} vs. your normal",
"deltaUnchanged": "matches your normal",
+2
View File
@@ -862,6 +862,8 @@
"emptyStateDescription": "Registra al menos 3 entradas para desbloquear la línea de tendencia.",
"needMoreDistinctDaysTitle": "Se necesitan más días distintos",
"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.",
"personalBaseline": "Tu media",
"deltaVsBaseline": "{delta} vs. tu media",
"deltaUnchanged": "coincide con tu media",
+2
View File
@@ -862,6 +862,8 @@
"emptyStateDescription": "Saisis au moins 3 entrées pour débloquer la courbe de tendance.",
"needMoreDistinctDaysTitle": "Plus de jours distincts requis",
"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.",
"personalBaseline": "Ta moyenne",
"deltaVsBaseline": "{delta} vs. ta moyenne",
"deltaUnchanged": "correspond à ta moyenne",
+2
View File
@@ -862,6 +862,8 @@
"emptyStateDescription": "Registra almeno 3 voci per sbloccare la linea di tendenza.",
"needMoreDistinctDaysTitle": "Servono più giorni distinti",
"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.",
"personalBaseline": "La tua media",
"deltaVsBaseline": "{delta} vs. la tua media",
"deltaUnchanged": "corrisponde alla tua media",
+2
View File
@@ -862,6 +862,8 @@
"emptyStateDescription": "Zarejestruj co najmniej 3 wpisy, aby odblokować linię trendu.",
"needMoreDistinctDaysTitle": "Potrzeba więcej różnych dni",
"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.",
"personalBaseline": "Twoja średnia",
"deltaVsBaseline": "{delta} vs. twoja średnia",
"deltaUnchanged": "odpowiada twojej średniej",
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.43 W11-M6 — chart empty-window state.
*
* Pre-fix the chart returned `null` when the loaded data array was
* empty in the selected range, erasing the entire card (header +
* tabs + chart). The user saw a missing widget without explanation.
* 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.
*/
function buildData(rawCount: number): unknown[] {
const points: unknown[] = [];
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-window state (W11-M6)", () => {
beforeEach(() => {
vi.resetModules();
});
it("renders the no-data-in-range copy when data resolves to an empty array", async () => {
const data = buildData(0);
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>,
);
// The new empty state must paint…
expect(html).toContain("Keine Daten in diesem Zeitraum");
// …and the chart card chrome (title) must still be mounted so the
// 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
// sparse-data are different situations with distinct copy.
expect(html).not.toContain("Mehr Messtage erforderlich");
expect(html).not.toContain("Erfasse mindestens 3 Einträge");
vi.doUnmock("@tanstack/react-query");
});
});
+31 -2
View File
@@ -1132,7 +1132,14 @@ export function HealthChart({
return map;
}, [chartData, types]);
if (!isLoading && !data?.length) return null;
// v1.4.43 W11-M6 — keep the card shell mounted even when the loaded
// payload is empty. Pre-fix the early `return null` here erased the
// whole widget (header + range tabs + chart) and the user saw an
// unexplained gap on the dashboard. The in-card render branch below
// now paints a "no data in this range" empty state so the user
// understands the chart loaded but the selected window holds no
// measurements — distinct copy from the < 3-points "more days
// needed" hint so the two situations don't blur.
const maxPointIndex = Math.max(0, (chartData?.length ?? 1) - 1);
@@ -1288,7 +1295,29 @@ export function HealthChart({
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : !chartData?.length ? null : (chartData?.length ?? 0) < 3 ? (
) : !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.
<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.