mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
1 parent
2e84498cf3
commit
dbc2b15b1b
4 files changed
+73
-37
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user