mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
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"');
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user