feat(lists): empty states for measurements, mood, medications, achievements

Wires the EmptyState primitive into the four feature lists. Each path
now exposes an icon + localized title + description + (where it
applies) a CTA bound to the parent dialog so brand-new accounts get a
single-click path to logging their first datapoint.

- /measurements — Activity icon, "Add your first measurement" CTA
  wired to the page's Add dialog. Filter-aware: switches to "no
  measurements match this filter" with a "Show all types" reset
  whenever a non-default type filter is active.
- /mood — Smile icon, "Log your first mood" CTA, identical
  filter-aware split for the mood-level filter.
- /medications — Refactor inline icon+text+button block to the shared
  primitive. Pill icon, "Add first medication" CTA → openCreate.
- /achievements — Trophy icon. Reuses the existing
  achievements.noneUnlockedYet string to keep C4 i18n sweep work
  minimal.

New unit test (measurement-list-empty) asserts the primitive mounts,
the Add-first CTA respects the optional onAddFirst prop (no orphaned
button when the parent doesn't wire it), and the reset-filter CTA
stays out of the default-filter render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 21:51:10 +02:00
1 parent 4e85c38c4b
commit 9a74f8e8f2
1 file changed
+91
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.15 phase-C5 — `/measurements` empty state.
*
* Before this phase the list rendered a single sentence
* ("No measurements yet") in a dashed-border box with no CTA. Brand-
* new accounts had to scroll back up to find the header "Add" button.
* The new EmptyState surface mounts the primitive with an Activity
* icon, localized copy, and an "Add your first measurement" CTA wired
* to the parent dialog.
*
* The filter-aware copy switches to "no measurements match this
* filter" + a "Show all types" reset CTA when a non-default filter is
* active — guarded indirectly by the absence assertion here (the test
* runs the default ALL filter; reset CTA must NOT appear).
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/measurements",
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: { measurements: [], meta: { total: 0 } },
isLoading: false,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "marc", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementList } from "../measurement-list";
function render(props: { onAddFirst?: () => void } = {}) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementList {...props} />
</I18nProvider>,
);
}
describe("MeasurementList — empty state", () => {
it("renders the EmptyState primitive when no measurements exist", () => {
const html = render();
expect(html).toContain('role="status"');
expect(html).toContain('aria-live="polite"');
expect(html).toContain("border-dashed");
});
it("renders the localized title and description", () => {
const html = render();
expect(html).toContain("No measurements yet");
expect(html).toContain(
"Log your weight, blood pressure, pulse, or other vitals",
);
});
it("exposes the Add-first CTA when onAddFirst is wired", () => {
const html = render({ onAddFirst: () => {} });
expect(html).toContain("Add your first measurement");
});
it("hides the Add-first CTA when no callback is provided", () => {
const html = render();
// The fallback is the parent's header CTA; the empty card itself
// must not render a button that does nothing.
expect(html).not.toContain("Add your first measurement");
});
it("does not render the reset-filter CTA on the default filter", () => {
const html = render();
expect(html).not.toContain("Show all types");
});
});