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

+7 -15
View File
@@ -6,6 +6,7 @@ import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/ui/page-header";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -125,19 +126,10 @@ export function CycleView() {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1
data-tour-id="cycle-hero"
className="text-2xl font-bold tracking-tight"
>
{t("cycle.title")}
</h1>
<p className="text-muted-foreground text-xs sm:text-sm">
{t("cycle.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<PageHeader
title={<span data-tour-id="cycle-hero">{t("cycle.title")}</span>}
description={t("cycle.subtitle")}
actions={
<Button
onClick={() => openSheet(today)}
className="min-h-11 sm:min-h-9"
@@ -145,8 +137,8 @@ export function CycleView() {
<Plus className="h-4 w-4" />
{t("cycle.logToday")}
</Button>
</div>
</div>
}
/>
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
+89 -93
View File
@@ -22,11 +22,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { PageHeader } from "@/components/ui/page-header";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
import { cn } from "@/lib/utils";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
export function DashboardHeader({
@@ -68,106 +68,102 @@ export function DashboardHeader({
: t("dashboard.welcomeBack", { greeting: timeGreeting });
return (
/* With the greeting the title block is two lines on narrow
viewports: centre the buttons against it on mobile, top-align on
sm+ (the pre-hero posture). Without the greeting the single-line
centre alignment stands. */
<div
className={cn(
"flex items-center justify-between gap-4",
showGreeting && "sm:items-start",
)}
>
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{t("dashboard.title")}
</h1>
{showGreeting ? (
<PageHeader
title={t("dashboard.title")}
description={
showGreeting ? (
/* `min-h-5` reserves the greeting's line box from the SSR
pass on: the text personalises (name appended) on the first
client re-render after hydration, and the reserved line
keeps the header from collapsing/growing around that swap. */
<p
<span
data-slot="dashboard-header-greeting"
className="text-muted-foreground mt-1 min-h-5 truncate text-sm"
className="block min-h-5 truncate"
>
{welcomeText}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* Customize shortcut to the dashboard-customization settings
(the tile/layout editor at /settings/dashboard). Sits to
the left of the add button as a monochrome ghost icon, with
a `min-h-11 min-w-11` mobile floor so it meets the 44 px
touch-target contract the add button also honours, shrinking
back to the 40 px icon footprint on sm+. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
data-testid="dashboard-customize-shortcut"
>
<Link
href="/settings/layout/dashboard"
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
</span>
) : undefined
}
actions={
<>
{/* Customize shortcut to the dashboard-customization settings
(the tile/layout editor at /settings/dashboard). Sits to
the left of the add button as a monochrome ghost icon, with
a `min-h-11 min-w-11` mobile floor so it meets the 44 px
touch-target contract the add button also honours, shrinking
back to the 40 px icon footprint on sm+. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
data-testid="dashboard-customize-shortcut"
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
across viewports. The v1.4.27 fix pinned a `size="sm"
min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
contract on mobile, but `size="sm"` is h-8 (32 px) and
the `min-h-11` override stretched the cap vertically
while keeping the small horizontal padding — the
button read as klobig on Pixel 5. Switch to
`size="default"` (h-10 = 40 px) on mobile with a
responsive `min-h-11 sm:min-h-9` so the button is
44 px tall under finger pressure and shrinks back to
the desktop-friendly 36 px on `sm:` upwards. The icon
+ label keep the same visual contract. */}
<Button
size="default"
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
<Link
href="/settings/layout/dashboard"
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-w-[calc(100vw-2rem)]">
{/* Menu items must each carry a self-contained verb-phrase
("Log measurement", "Log mood") — the trigger above already
says "Add", and the icon is `aria-hidden`, so the visible
text is the only thing distinguishing the rows. v1.4.15
phase-A3 fix #1 hardened this with a unit guard at
`src/app/__tests__/quick-add-labels.test.ts` — both labels
must differ from each other AND from `common.add`. */}
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
{/* v1.4.37 W7b — third quick-add row: medication intake.
Same Sheet-on-mobile / Dialog-on-desktop primitive as
the other two; the menu label is a self-contained
verb-phrase so it doesn't collide with the trigger or
the sibling rows (cf. quick-add-labels.test.ts). */}
<DropdownMenuItem onClick={() => onQuickEntry("medicationIntake")}>
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
across viewports. The v1.4.27 fix pinned a `size="sm"
min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
contract on mobile, but `size="sm"` is h-8 (32 px) and
the `min-h-11` override stretched the cap vertically
while keeping the small horizontal padding — the
button read as klobig on Pixel 5. Switch to
`size="default"` (h-10 = 40 px) on mobile with a
responsive `min-h-11 sm:min-h-9` so the button is
44 px tall under finger pressure and shrinks back to
the desktop-friendly 36 px on `sm:` upwards. The icon
+ label keep the same visual contract. */}
<Button
size="default"
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-w-[calc(100vw-2rem)]"
>
{/* Menu items must each carry a self-contained verb-phrase
("Log measurement", "Log mood") — the trigger above already
says "Add", and the icon is `aria-hidden`, so the visible
text is the only thing distinguishing the rows. v1.4.15
phase-A3 fix #1 hardened this with a unit guard at
`src/app/__tests__/quick-add-labels.test.ts` — both labels
must differ from each other AND from `common.add`. */}
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
{/* v1.4.37 W7b — third quick-add row: medication intake.
Same Sheet-on-mobile / Dialog-on-desktop primitive as
the other two; the menu label is a self-contained
verb-phrase so it doesn't collide with the trigger or
the sibling rows (cf. quick-add-labels.test.ts). */}
<DropdownMenuItem
onClick={() => onQuickEntry("medicationIntake")}
>
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
);
}
+41 -63
View File
@@ -17,13 +17,7 @@
*/
import { useId, useMemo, useState } from "react";
import Link from "next/link";
import {
ChevronDown,
Plus,
RefreshCw,
Stethoscope,
Wrench,
} from "lucide-react";
import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -35,6 +29,8 @@ import {
CardTitle,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -379,46 +375,39 @@ export function IllnessView() {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1
data-tour-id="illness-hero"
className="text-2xl font-bold tracking-tight"
>
{t("illness.title")}
</h1>
<p className="text-muted-foreground text-xs sm:text-sm">
{t("illness.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
Illness settings page (view + reorder). */}
<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/illness"
aria-label={t("illness.customize")}
title={t("illness.customize")}
<PageHeader
title={<span data-tour-id="illness-hero">{t("illness.title")}</span>}
description={t("illness.subtitle")}
actions={
<>
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
Illness settings page (view + reorder). */}
<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>
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
other module, not the bespoke "neue Episode". */}
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</div>
</div>
<Link
href="/settings/layout/illness"
aria-label={t("illness.customize")}
title={t("illness.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
other module, not the bespoke "neue Episode". */}
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</>
}
/>
{/* v1.18.6 (DISC-01) — the per-page medical disclaimer line is removed;
the one-time acknowledgment now lives at onboarding and the legal
@@ -430,23 +419,12 @@ export function IllnessView() {
<Skeleton className="h-32 w-full" />
</div>
) : isError ? (
<div
role="alert"
data-slot="illness-list-error"
className="text-muted-foreground flex flex-col items-start gap-3 py-8 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("illness.listLoadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void refetch()}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
// A read failure is NOT an empty journal — surface the error + Retry so
// an outage never reads as "no episodes yet".
<QueryErrorCard
title={t("illness.listLoadError")}
onRetry={() => void refetch()}
/>
) : hasEpisodes ? (
<>
{/* Retrospective summary sits above the list so a long history
+8 -4
View File
@@ -9,6 +9,7 @@ import { MedicationCardHeader } from "@/components/medications/medication-card-h
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
@@ -93,7 +94,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
sortDir: "desc",
});
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: listKey,
queryFn: () =>
apiGet<LabResultListResponse>("/api/labs?limit=500&sortDir=desc"),
@@ -149,10 +150,13 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
}
if (isError) {
// A read failure is NOT an empty list — surface the error + Retry so an
// outage never reads as "you have no lab results".
return (
<p role="alert" className="text-destructive py-8 text-center text-sm">
{t("labs.loadError")}
</p>
<QueryErrorCard
title={t("labs.loadError")}
onRetry={() => void refetch()}
/>
);
}
@@ -6,6 +6,8 @@ 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 { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { ListRow } from "@/components/ui/list-row";
import {
FilterBar,
@@ -461,7 +463,7 @@ export function MeasurementList({
? "groupBy=day"
: "raw";
const { data, isLoading } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.measurementsList({
type: typeFilter === "ALL" ? undefined : typeFilter,
sourceEq,
@@ -825,9 +827,15 @@ export function MeasurementList({
)}
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
<div className="space-y-2" data-slot="measurement-list-loading">
{Array.from({ length: 6 }, (_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
) : isError ? (
// A read failure is NOT an empty list — surface the error + Retry so
// an outage never reads as "you have no measurements".
<QueryErrorCard onRetry={() => void refetch()} />
) : !data?.measurements?.length ? (
// v1.4.15 phase-C5: replaces the bare-text empty rectangle.
// Filter-aware copy distinguishes brand-new accounts ("no
@@ -26,6 +26,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { PageHeader } from "@/components/ui/page-header";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { AssessmentHistory } from "./assessment-history";
@@ -107,14 +108,12 @@ export function MentalWellbeing() {
<div className="flex flex-col gap-6">
{phase === "choose" && (
<>
<header className="flex flex-col gap-2">
{/* Title kept for screen readers / document outline only — the
visible heading + its tooltip were removed (§ Marc v1.25.5). */}
<h1 className="sr-only">{t("mentalHealth.pageTitle")}</h1>
<p className="text-muted-foreground text-sm">
{t("mentalHealth.pageDescription")}
</p>
</header>
{/* v1.26 — visible page header, consistent with every other module
(the title was previously sr-only; the description stays). */}
<PageHeader
title={t("mentalHealth.pageTitle")}
description={t("mentalHealth.pageDescription")}
/>
<section aria-label={t("mentalHealth.choosePrompt")}>
<h2 className="sr-only">{t("mentalHealth.choosePrompt")}</h2>
+12 -20
View File
@@ -5,6 +5,8 @@ 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 { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import {
FilterBar,
FilterBarDateRange,
@@ -43,7 +45,6 @@ import {
Loader2,
Pencil,
Plus,
RefreshCw,
Smile,
Trash2,
ChevronLeft,
@@ -500,27 +501,18 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
</FilterBar>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
<div className="space-y-2" data-slot="mood-list-loading">
{Array.from({ length: 6 }, (_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
) : isError ? (
<div
role="alert"
data-slot="mood-list-error"
className="text-muted-foreground flex flex-col items-start gap-3 py-8 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("mood.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void refetch()}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
// A read failure is NOT an empty list — surface the error + Retry so
// an outage never reads as "you have no mood entries".
<QueryErrorCard
title={t("mood.loadError")}
onRetry={() => void refetch()}
/>
) : !data?.entries?.length ? (
// v1.4.15 phase-C5: replace bare-text empty rectangle with
// EmptyState. Filter-aware copy splits "no mood entries yet"
+43
View File
@@ -0,0 +1,43 @@
import type { ReactNode } from "react";
import { BackLink } from "@/components/ui/back-link";
import { cn } from "@/lib/utils";
/**
* The one canonical page header. Every module surface renders its title
* through this so the header vocabulary reads identically app-wide: the H1
* is always `text-2xl font-bold tracking-tight`, the description is always
* `text-muted-foreground text-sm`, and no icon sits beside the H1. An
* optional back-link rides above the title, and an `actions` slot holds the
* page's primary buttons to the right of the title block.
*/
export function PageHeader({
title,
description,
backLink,
actions,
className,
}: {
title: ReactNode;
description?: ReactNode;
backLink?: { href: string; label: string; dataSlot?: string };
actions?: ReactNode;
className?: string;
}) {
return (
<div className={cn("space-y-1.5", className)}>
{backLink ? <BackLink {...backLink} /> : null}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1.5">
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 items-center gap-2">{actions}</div>
) : null}
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import type { ReactNode } from "react";
import { AlertTriangle, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* Read-failure card for list / data surfaces.
*
* Mirrors `<ChartErrorState>`'s shape + tone (AlertTriangle, a title line, a
* muted description, an optional Retry button) but renders inside a `Card` so
* a list page can tell "load failed" apart from an honest empty state — a
* failed query must never fall through to the "no data yet" copy. Defaults are
* the generic `common.*` strings so a caller can drop it in with just
* `onRetry`; a surface with its own "couldn't load X" string passes it through
* `title` / `description`.
*/
export function QueryErrorCard({
title,
description,
onRetry,
retryLabel,
className,
}: {
title?: ReactNode;
description?: ReactNode;
onRetry?: () => void;
retryLabel?: string;
className?: string;
}) {
const { t } = useTranslations();
return (
<Card data-slot="query-error-card" className={cn(className)}>
<CardContent
role="alert"
className="flex flex-col items-center justify-center gap-3 px-6 py-10 text-center"
>
<AlertTriangle
className="text-muted-foreground/60 h-8 w-8"
aria-hidden="true"
/>
<p className="text-sm font-medium">{title ?? t("common.loadFailed")}</p>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
{onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
{retryLabel ?? t("common.retry")}
</Button>
) : null}
</CardContent>
</Card>
);
}