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

Follow-up to 9a74f8e — that commit's pathspec didn't pick up the
implementation files, only the new test. This commit lands the actual
EmptyState wiring described in the previous message:

- src/components/measurements/measurement-list.tsx — Activity icon,
  filter-aware empty state, optional onAddFirst CTA prop.
- src/components/mood/mood-list.tsx — Smile icon, mirroring filter
  semantics + optional onAddFirst.
- src/app/measurements/page.tsx + src/app/mood/page.tsx — pass
  onAddFirst so the empty-state CTA opens the same dialog as the
  header button.
- src/app/medications/page.tsx — refactor inline icon+text+button
  block to the EmptyState primitive (Pill icon).
- src/app/achievements/page.tsx — Trophy icon for the no-achievements
  state, reusing the existing noneUnlockedYet string.

Together with 9a74f8e the four feature lists now share a consistent
empty-state surface with the rest of the app.

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:48 +02:00
1 parent 9a74f8e8f2
commit 1d65f3b925
6 files changed
+116 -23

No files matched your search

+9 -5
View File
@@ -23,6 +23,7 @@ import {
} from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
@@ -336,11 +337,14 @@ export default function AchievementsPage() {
</div>
{grouped.length === 0 ? (
<div className="bg-card border-border rounded-xl border p-6 text-center">
<p className="text-muted-foreground text-sm">
{t("achievements.noneUnlockedYet")}
</p>
</div>
// v1.4.15 phase-C5: upgrade to the EmptyState primitive so the
// surface matches the rest of the app. Trophy icon makes the
// intent obvious before the user reads the copy. Reuses the
// existing `noneUnlockedYet` string to avoid C4 i18n churn.
<EmptyState
icon={<Trophy className="size-6" />}
title={t("achievements.noneUnlockedYet")}
/>
) : (
<div className="space-y-6">
{grouped.map(({ category, items }) => {
+1 -1
View File
@@ -67,7 +67,7 @@ export default function MeasurementsPage() {
</Dialog>
</div>
<MeasurementList />
<MeasurementList onAddFirst={() => setDialogOpen(true)} />
</div>
);
}
+14 -8
View File
@@ -9,6 +9,7 @@ import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { MedicationForm } from "@/components/medications/medication-form";
import { MedicationCard } from "@/components/medications/medication-card";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
@@ -173,16 +174,21 @@ export default function MedicationsPage() {
</div>
</div>
) : !medications?.length ? (
<div className="bg-card border-border flex h-64 items-center justify-center rounded-xl border">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Pill className="h-8 w-8" />
<p>{t("medications.noMedicationsYet")}</p>
<Button variant="outline" size="sm" onClick={openCreate}>
<Plus className="mr-1 h-3.5 w-3.5" />
// v1.4.15 phase-C5: refactor the inline icon+text+button block
// to the shared EmptyState primitive so the empty path matches
// every other list page in the app (role=status, dashed
// border, consistent icon-bubble + spacing).
<EmptyState
icon={<Pill className="size-6" />}
title={t("medications.emptyTitle")}
description={t("medications.emptyDescription")}
action={
<Button size="sm" onClick={openCreate}>
<Plus className="mr-1 h-4 w-4" />
{t("medications.firstMedication")}
</Button>
</div>
</div>
}
/>
) : (
<div className="space-y-5">
{/* Active medications */}
+1 -1
View File
@@ -67,7 +67,7 @@ export default function MoodPage() {
</Dialog>
</div>
<MoodList />
<MoodList onAddFirst={() => setDialogOpen(true)} />
</div>
);
}
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import {
Select,
SelectContent,
@@ -45,8 +46,10 @@ import {
TableRow,
} from "@/components/ui/table";
import {
Activity,
Loader2,
Pencil,
Plus,
Trash2,
ChevronLeft,
ChevronRight,
@@ -77,6 +80,13 @@ interface Measurement {
interface MeasurementListProps {
onEdit?: (m: Measurement) => void;
/**
* v1.4.15 phase-C5: optional callback wired by the parent page so
* the empty-state's "Add your first measurement" CTA opens the same
* dialog the header button does. When undefined we hide the button
* (fallback to the header CTA) instead of crashing.
*/
onAddFirst?: () => void;
}
const PAGE_SIZE = 25;
@@ -94,7 +104,7 @@ function toDateTimeLocalValue(isoString: string): string {
return local.toISOString().slice(0, 16);
}
export function MeasurementList({ onEdit }: MeasurementListProps) {
export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
@@ -301,9 +311,40 @@ export function MeasurementList({ onEdit }: MeasurementListProps) {
<Loader2 className="text-primary h-6 w-6 animate-spin" />
</div>
) : !data?.measurements?.length ? (
<div className="text-muted-foreground flex h-32 items-center justify-center rounded-lg border border-dashed">
{t("measurements.noMeasurements")}
</div>
// v1.4.15 phase-C5: replaces the bare-text empty rectangle.
// Filter-aware copy distinguishes brand-new accounts ("no
// measurements yet") from filtered-out lists ("no measurements
// match this filter") and exposes the right CTA for each
// case.
<EmptyState
icon={<Activity className="size-6" />}
title={
typeFilter === "ALL"
? t("measurements.emptyTitle")
: t("measurements.emptyFilteredTitle")
}
description={
typeFilter === "ALL"
? t("measurements.emptyDescription")
: t("measurements.emptyFilteredDescription")
}
action={
typeFilter !== "ALL" ? (
<Button
variant="outline"
size="sm"
onClick={() => setTypeFilter("ALL")}
>
{t("measurements.emptyResetFilter")}
</Button>
) : onAddFirst ? (
<Button size="sm" onClick={onAddFirst}>
<Plus className="mr-1 h-4 w-4" />
{t("measurements.emptyAddFirst")}
</Button>
) : undefined
}
/>
) : (
<>
{/* Desktop table */}
+46 -4
View File
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import {
Select,
SelectContent,
@@ -47,6 +48,8 @@ import {
import {
Loader2,
Pencil,
Plus,
Smile,
Trash2,
ChevronLeft,
ChevronRight,
@@ -103,7 +106,17 @@ function toDateTimeLocalValue(isoString: string): string {
return local.toISOString().slice(0, 16);
}
export function MoodList() {
interface MoodListProps {
/**
* v1.4.15 phase-C5: optional callback wired by the parent page so
* the empty-state's "Log your first mood" CTA opens the same dialog
* the header button does. When undefined, the CTA hides instead of
* rendering a no-op button.
*/
onAddFirst?: () => void;
}
export function MoodList({ onAddFirst }: MoodListProps = {}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
@@ -296,9 +309,38 @@ export function MoodList() {
<Loader2 className="text-primary h-6 w-6 animate-spin" />
</div>
) : !data?.entries?.length ? (
<div className="text-muted-foreground flex h-32 items-center justify-center rounded-lg border border-dashed">
{t("mood.noEntries")}
</div>
// v1.4.15 phase-C5: replace bare-text empty rectangle with
// EmptyState. Filter-aware copy splits "no mood entries yet"
// (brand-new account) from "no entries match this filter".
<EmptyState
icon={<Smile className="size-6" />}
title={
moodFilter === "ALL"
? t("mood.emptyTitle")
: t("mood.emptyFilteredTitle")
}
description={
moodFilter === "ALL"
? t("mood.emptyDescription")
: t("mood.emptyFilteredDescription")
}
action={
moodFilter !== "ALL" ? (
<Button
variant="outline"
size="sm"
onClick={() => setMoodFilter("ALL")}
>
{t("mood.emptyResetFilter")}
</Button>
) : onAddFirst ? (
<Button size="sm" onClick={onAddFirst}>
<Plus className="mr-1 h-4 w-4" />
{t("mood.emptyAddFirst")}
</Button>
) : undefined
}
/>
) : (
<>
{/* Desktop table */}