fix(coach,insights,achievements): honest error states + Plans discoverability

- Achievements: a failed load renders QueryErrorCard instead of falling
  through to zeroed summary tiles and a 'none unlocked yet' empty grid.
- /coach/conversations: a load failure now shows a retry instead of a false
  'no conversations yet' empty state.
- Insights overview error uses the shared QueryErrorCard.
- Coach Plans gains a header cross-link from /coach/conversations (and back),
  a skeleton loading state, and the shared EmptyState primitive.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 23:44:10 +02:00
1 parent 2e84498cf3
commit dbc2b15b1b
4 files changed
+73 -37

No files matched your search

+17 -1
View File
@@ -33,6 +33,7 @@ import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
@@ -271,7 +272,7 @@ export default function AchievementsPage() {
// alongside `<RecentAchievementsCard>` and
// `<AchievementUnlockNotifier>` so the three consumers never trigger
// more than one network call on a cold dashboard mount.
const { data, isLoading } = useAchievementsQuery({
const { data, isLoading, isError, refetch } = useAchievementsQuery({
enabled: isAuthenticated && achievementsEnabled,
});
@@ -302,6 +303,21 @@ export default function AchievementsPage() {
);
}
// A failed load must never fall through to the zeroed summary tiles + the
// "none unlocked yet" empty grid (UI-STANDARDS §6). Surface an honest
// retry instead.
if (isError) {
return (
<div className="space-y-6">
<PageHeader
title={t("achievements.title")}
description={t("achievements.subtitle")}
/>
<QueryErrorCard onRetry={() => refetch()} />
</div>
);
}
const summary = data?.summary;
const achievements = data?.achievements ?? [];
const grouped = groupByCategory(achievements);
+20 -2
View File
@@ -1,12 +1,14 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { MessagesSquare, Search, Trash2 } from "lucide-react";
import { MessagesSquare, Search, Target, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
import {
useCoachConversations,
@@ -92,7 +94,8 @@ function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] {
function CoachConversationsBody() {
const { t } = useTranslations();
const router = useRouter();
const { conversations, isLoading } = useCoachConversations();
const { conversations, isLoading, isError, refetch } =
useCoachConversations();
const deleteMutation = useDeleteCoachConversation();
const [filter, setFilter] = useState<string>("");
const [confirmId, setConfirmId] = useState<string | null>(null);
@@ -129,6 +132,16 @@ function CoachConversationsBody() {
{t("insights.coach.historyTitle")}
</span>
}
actions={
// Surface the sibling Plans ledger — otherwise it is reachable
// only from the composer's `+` menu.
<Button asChild variant="outline" size="sm">
<Link href="/coach/plans" data-slot="coach-conversations-plans-link">
<Target className="size-4" aria-hidden="true" />
{t("coach.plans.title")}
</Link>
</Button>
}
/>
<div className="relative">
<Search
@@ -160,6 +173,11 @@ function CoachConversationsBody() {
>
{t("common.loading")}
</p>
) : isError && conversations.length === 0 ? (
// A load failure must not masquerade as "no conversations yet" —
// that reads as a data-loss scare and offers no way back. Mirror
// /coach/plans and surface a retry.
<QueryErrorCard onRetry={() => refetch()} />
) : groups.length === 0 ? (
<div
data-slot="coach-conversations-empty"
+29 -16
View File
@@ -1,12 +1,15 @@
"use client";
import { useEffect, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Check, Loader2, Target, Trash2, X } from "lucide-react";
import { Check, Loader2, MessagesSquare, Target, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
import {
useCoachPlans,
@@ -204,6 +207,17 @@ function CoachPlansBody() {
<span data-slot="coach-plans-heading">{t("coach.plans.title")}</span>
}
description={t("coach.plans.pageDescription")}
actions={
<Button asChild variant="outline" size="sm">
<Link
href="/coach/conversations"
data-slot="coach-plans-conversations-link"
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Link>
</Button>
}
/>
<div
@@ -216,23 +230,22 @@ function CoachPlansBody() {
{query.isError ? (
<QueryErrorCard onRetry={() => query.refetch()} />
) : query.isLoading ? (
<p
data-slot="coach-plans-loading"
className="text-muted-foreground px-1 py-3 text-sm"
>
{t("common.loading")}
</p>
) : groups.length === 0 ? (
<div
data-slot="coach-plans-empty"
className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-12 text-center"
data-slot="coach-plans-loading"
className="flex flex-col gap-2"
aria-hidden="true"
>
<span className="bg-muted/40 text-muted-foreground flex size-12 items-center justify-center rounded-full">
<Target className="size-6" aria-hidden="true" />
</span>
<p className="text-muted-foreground max-w-xs text-sm leading-relaxed">
{t("coach.plans.empty")}
</p>
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-xl" />
))}
</div>
) : groups.length === 0 ? (
<div data-slot="coach-plans-empty">
<EmptyState
variant="plain"
icon={<Target className="size-6" />}
title={t("coach.plans.empty")}
/>
</div>
) : (
groups.map((group) => (
+7 -18
View File
@@ -4,7 +4,7 @@ import { Fragment, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { RefreshCw, SlidersHorizontal, TrendingUp } from "lucide-react";
import { SlidersHorizontal, TrendingUp } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -20,6 +20,7 @@ import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
@@ -340,23 +341,11 @@ export default function InsightsPage() {
// mirroring the <VitalsDashboard> error pattern.
if (isError) {
return (
<div
data-slot="insights-overview-error"
role="alert"
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("insights.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
data-slot="insights-overview-retry"
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
<div data-slot="insights-overview-error">
<QueryErrorCard
description={t("insights.loadError")}
onRetry={() => refetch()}
/>
</div>
);
}