refactor(ui): unify page headers and surface read failures

Add a PageHeader primitive (one H1 vocabulary, optional description,
back-link, and action slots) and route the hand-rolled headers through
it, so every module reads with the same title/subtitle rhythm and no
icon beside the page title. Mental Wellbeing gains a visible header.
Add a QueryErrorCard and wire the list surfaces so a failed read shows
a retry card instead of falling through to the empty state, and swap
their full-page spinners for skeletons that hold the layout.
This commit is contained in:
Marc-André Bombeck committed 2026-07-02 00:00:37 +02:00
1 parent 336ce5aef3
commit 887c79ec86
16 files changed
+413 -378

No files matched your search

@@ -74,7 +74,7 @@ describe("dashboard hero — header handoff", () => {
// prop gates the paragraph, and the `min-h-5` line-box reservation
// keeps the header stable through the post-hydration name swap.
expect(header).toContain("showGreeting");
expect(header).toMatch(/\{showGreeting\s*\?/);
expect(header).toMatch(/\{\s*showGreeting\s*\?/);
expect(header).toContain("min-h-5");
expect(header).toContain('data-slot="dashboard-header-greeting"');
});
+12 -19
View File
@@ -32,6 +32,7 @@ import {
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 { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
@@ -293,12 +294,10 @@ export default function AchievementsPage() {
if (!isAuthenticated) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold tracking-tight">
{t("achievements.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("achievements.loginRequired")}
</p>
<PageHeader
title={t("achievements.title")}
description={t("achievements.loginRequired")}
/>
</div>
);
}
@@ -313,20 +312,14 @@ export default function AchievementsPage() {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1
data-tour-id="achievements-hero"
className="text-2xl font-bold tracking-tight"
>
<PageHeader
title={
<span data-tour-id="achievements-hero">
{t("achievements.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("achievements.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2"></div>
</div>
</span>
}
description={t("achievements.subtitle")}
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
+8 -7
View File
@@ -6,6 +6,7 @@ import { MessagesSquare, Search, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PageHeader } from "@/components/ui/page-header";
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
import {
useCoachConversations,
@@ -122,13 +123,13 @@ function CoachConversationsBody() {
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-screen-xl flex-col gap-4 px-4 pt-6 pb-4 md:px-6">
<header className="flex flex-col gap-3">
<h1
data-slot="coach-conversations-heading"
className="text-foreground flex items-center gap-2 text-lg font-semibold"
>
<MessagesSquare className="size-5" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</h1>
<PageHeader
title={
<span data-slot="coach-conversations-heading">
{t("insights.coach.historyTitle")}
</span>
}
/>
<div className="relative">
<Search
aria-hidden="true"
+58 -64
View File
@@ -17,6 +17,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { PageHeader } from "@/components/ui/page-header";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useAuth } from "@/hooks/use-auth";
@@ -66,73 +67,66 @@ export default function LabsPage() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1
data-tour-id="labs-hero"
className="text-2xl font-bold tracking-tight"
>
{t("labs.title")}
</h1>
<p className="text-muted-foreground text-xs sm:text-sm">
{t("labs.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
point, left of the primary Add and linking to the Labs settings
page (view, sort order, biomarker CRUD + reorder). Mirrors the
medication page header's wrench glyph + slot + 44px tap floor. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/labs"
aria-label={t("labs.customize")}
title={t("labs.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
{/* v1.18.10 — the "Add" action is a CHOICE when scanning is
available: scan a document (OCR) or add a value by hand. The scan
option only appears when the capability probe reports a usable
mode (vision, or local OCR opted-in for text-only providers), so
the surface stays simple for everyone else. When scanning is
unavailable, Add opens the manual form directly. */}
{ocrCapability.data?.available ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setScanOpen(true)}>
<ScanLine className="h-4 w-4" aria-hidden="true" />
{t("labs.ocr.addScan")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setDialogOpen(true)}>
<Pencil className="h-4 w-4" aria-hidden="true" />
{t("labs.ocr.addManual")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<PageHeader
title={<span data-tour-id="labs-hero">{t("labs.title")}</span>}
description={t("labs.subtitle")}
actions={
<>
{/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
point, left of the primary Add and linking to the Labs settings
page (view, sort order, biomarker CRUD + reorder). Mirrors the
medication page header's wrench glyph + slot + 44px tap floor. */}
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Plus className="h-4 w-4" />
{t("common.add")}
<Link
href="/settings/layout/labs"
aria-label={t("labs.customize")}
title={t("labs.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
</div>
</div>
{/* v1.18.10 — the "Add" action is a CHOICE when scanning is
available: scan a document (OCR) or add a value by hand. The scan
option only appears when the capability probe reports a usable
mode (vision, or local OCR opted-in for text-only providers), so
the surface stays simple for everyone else. When scanning is
unavailable, Add opens the manual form directly. */}
{ocrCapability.data?.available ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setScanOpen(true)}>
<ScanLine className="h-4 w-4" aria-hidden="true" />
{t("labs.ocr.addScan")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setDialogOpen(true)}>
<Pencil className="h-4 w-4" aria-hidden="true" />
{t("labs.ocr.addManual")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
)}
</>
}
/>
<ResponsiveSheet
open={dialogOpen}
+10 -17
View File
@@ -13,6 +13,7 @@ import { MeasurementList } from "@/components/measurements/measurement-list";
import { CustomMetricList } from "@/components/custom-metrics/custom-metric-list";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Plus, Loader2 } from "lucide-react";
@@ -125,22 +126,14 @@ export default function MeasurementsPage() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<div className="flex items-center justify-between">
<div className="min-w-0">
<h1
data-tour-id="measurements-hero"
className="text-2xl font-bold tracking-tight"
>
<PageHeader
title={
<span data-tour-id="measurements-hero">
{t("measurements.title")}
</h1>
{/* v1.4.34 IW-G — subtitle now stays visible on mobile so
the H1 isn't an unframed label. `text-xs sm:text-sm`
preserves the desktop hierarchy. */}
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{t("measurements.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
</span>
}
description={t("measurements.subtitle")}
actions={
<Button
className="min-h-11 sm:min-h-9"
onClick={() => setDialogOpen(true)}
@@ -148,8 +141,8 @@ export default function MeasurementsPage() {
<Plus className="h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
</div>
</div>
}
/>
<ResponsiveSheet
open={dialogOpen}
+34 -42
View File
@@ -9,6 +9,7 @@ import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { MoodForm } from "@/components/mood/mood-form";
import { MoodList } from "@/components/mood/mood-list";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Plus, Loader2, Wrench } from "lucide-react";
@@ -63,49 +64,40 @@ export default function MoodPage() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<div className="flex items-center justify-between">
<div className="min-w-0">
<h1
data-tour-id="mood-hero"
className="text-2xl font-bold tracking-tight"
>
{t("mood.title")}
</h1>
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
H1 isn't an unframed label. */}
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{t("mood.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.17 — the wrench is the one customize entry point: it
links to /settings/mood, which owns the tag groups, custom
tags, visibility, and picker order. Same glyph, slot (left
of the add button) and responsive 44-px mobile tap floor
as the medications header. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/mood"
aria-label={t("mood.customize")}
title={t("mood.customize")}
<PageHeader
title={<span data-tour-id="mood-hero">{t("mood.title")}</span>}
description={t("mood.subtitle")}
actions={
<>
{/* v1.17 — the wrench is the one customize entry point: it
links to /settings/mood, which owns the tag groups, custom
tags, visibility, and picker order. Same glyph, slot (left
of the add button) and responsive 44-px mobile tap floor
as the medications header. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
</Button>
</div>
</div>
<Link
href="/settings/layout/mood"
aria-label={t("mood.customize")}
title={t("mood.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
</Button>
</>
}
/>
<ResponsiveSheet
open={dialogOpen}
+13 -22
View File
@@ -6,6 +6,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import Link from "next/link";
import { Bell, Loader2, Settings, AlertCircle } from "lucide-react";
import {
@@ -129,12 +130,10 @@ export default function NotificationsPage() {
if (!isAuthenticated) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("notifications.loginRequired")}
</p>
<PageHeader
title={t("notifications.title")}
description={t("notifications.loginRequired")}
/>
</div>
);
}
@@ -146,14 +145,10 @@ export default function NotificationsPage() {
if (isError) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("notifications.subtitle")}
</p>
</div>
<PageHeader
title={t("notifications.title")}
description={t("notifications.subtitle")}
/>
<div
role="alert"
className="border-destructive/40 bg-destructive/10 text-destructive rounded-lg border p-4 text-sm"
@@ -168,14 +163,10 @@ export default function NotificationsPage() {
if (activeChannels.length === 0) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("notifications.subtitle")}
</p>
</div>
<PageHeader
title={t("notifications.title")}
description={t("notifications.subtitle")}
/>
<div className="bg-card border-border max-w-2xl rounded-xl border p-6">
<div className="flex flex-col items-center gap-4 py-8 text-center">
<Bell className="text-muted-foreground h-12 w-12" />