mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Hidden states of the floating coach button now leave the tab order and accessibility tree, its focus ring carries an offset, its icon is dark on the gradient, an unread nudge is announced once via a live region, and it sits correctly above the fold on tablet widths and yields to the onboarding tour. Chart error cards announce themselves, share the exact height variables of the skeleton band, carry per-chart retry labels, and a failed chunk renders inside the regular card shell. The header icon cluster spaces its extended hit areas apart, the tab strip keeps horizontal paint room for focus rings, the streak row reserves its slot in every card state, the selection bar clears the bottom nav on landscape phones, and the version poller keeps an in-memory reload guard for browsers that block session storage. Mood notes render as plain paragraphs with a toggle that appears only when text is actually clamped, the remember control reveals on hover and hands focus to its confirmation, and the hypnogram names its source and paints only the lanes the night contains.
165 lines
5.2 KiB
TypeScript
165 lines
5.2 KiB
TypeScript
"use client";
|
||
|
||
import { Component, type ReactElement, type ReactNode } from "react";
|
||
import { AlertTriangle, RotateCw } from "lucide-react";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import { useTranslations } from "@/lib/i18n/context";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
/**
|
||
* Error-state card + per-chart error boundary.
|
||
*
|
||
* Two failure classes used to look like something else:
|
||
* - A failed data query fell into the "no data in this range" empty
|
||
* state (the queries swallowed per-type failures as empty success),
|
||
* so an outage read as "you have no measurements".
|
||
* - A rejected lazy chunk import (stale shell after a deploy) bubbled
|
||
* to the route-level `error.tsx` and replaced the WHOLE page.
|
||
*
|
||
* `<ChartErrorState>` is the shared presentational card — same dashed
|
||
* footprint as `<ChartEmptyState>` so the dashboard layout never
|
||
* reflows — with a compact action button. `<ChartErrorBoundary>` wraps
|
||
* each lazy chart mount so a failed chunk degrades to one card with a
|
||
* reload affordance instead of taking the page down.
|
||
*/
|
||
|
||
export interface ChartErrorStateProps {
|
||
/** Translated headline, e.g. "Data could not be loaded". */
|
||
title: string;
|
||
/** Translated action-button label, e.g. "Retry". */
|
||
actionLabel: string;
|
||
/** Action handler — query `refetch` or a full page reload. */
|
||
onAction: () => void;
|
||
/**
|
||
* Optional explicit height in pixels for mini / mounted-in-a-strip
|
||
* contexts. Absent, the card sizes through the same
|
||
* `--chart-height` / `--chart-height-md` variables the painted chart
|
||
* reads, so a per-mount override resizes the error card too.
|
||
*/
|
||
height?: number;
|
||
/**
|
||
* Optional translated context (usually the chart title). Joins the
|
||
* action label into the button's accessible name so several retry
|
||
* buttons on one page stay distinguishable for screen-reader users.
|
||
*/
|
||
actionContext?: string;
|
||
/**
|
||
* Live-region semantics. `status` (default) announces politely when
|
||
* a data query fails inside an otherwise healthy card; the chunk
|
||
* boundary fallback passes `alert` because the surrounding chart is
|
||
* gone entirely.
|
||
*/
|
||
role?: "status" | "alert";
|
||
}
|
||
|
||
export function ChartErrorState({
|
||
title,
|
||
actionLabel,
|
||
onAction,
|
||
height,
|
||
actionContext,
|
||
role = "status",
|
||
}: ChartErrorStateProps): ReactElement {
|
||
return (
|
||
<div
|
||
data-slot="chart-error-state"
|
||
role={role}
|
||
className={cn(
|
||
"border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed px-6 text-center",
|
||
height === undefined &&
|
||
"h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]",
|
||
)}
|
||
style={height === undefined ? undefined : { height }}
|
||
>
|
||
<AlertTriangle
|
||
className="text-muted-foreground/60 h-8 w-8"
|
||
aria-hidden="true"
|
||
/>
|
||
<p className="text-sm font-medium">{title}</p>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={onAction}
|
||
aria-label={
|
||
actionContext ? `${actionLabel} – ${actionContext}` : undefined
|
||
}
|
||
>
|
||
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||
{actionLabel}
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface BoundaryProps {
|
||
fallback: ReactNode;
|
||
children: ReactNode;
|
||
}
|
||
|
||
interface BoundaryState {
|
||
failed: boolean;
|
||
}
|
||
|
||
class Boundary extends Component<BoundaryProps, BoundaryState> {
|
||
state: BoundaryState = { failed: false };
|
||
|
||
static getDerivedStateFromError(): BoundaryState {
|
||
return { failed: true };
|
||
}
|
||
|
||
componentDidCatch(error: Error): void {
|
||
// Surface to the client-side error tracker the same way the
|
||
// route-level boundary does — the card degrades quietly but the
|
||
// failure still lands in monitoring.
|
||
if (typeof window !== "undefined") {
|
||
const g = window as typeof window & {
|
||
__healthlog_onError?: (err: Error) => void;
|
||
};
|
||
g.__healthlog_onError?.(error);
|
||
}
|
||
}
|
||
|
||
render(): ReactNode {
|
||
return this.state.failed ? this.props.fallback : this.props.children;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Per-card boundary for lazy chart mounts. A chunk that still rejects
|
||
* after the `importWithRetry` attempt (see `src/lib/retry-import.ts`)
|
||
* renders this card instead of bubbling to the route boundary; the
|
||
* reload affordance fetches the fresh shell + chunk graph (a rejected
|
||
* lazy import is cached, so a remount alone cannot recover).
|
||
*/
|
||
export function ChartErrorBoundary({
|
||
children,
|
||
}: {
|
||
children: ReactNode;
|
||
}): ReactElement {
|
||
const { t } = useTranslations();
|
||
return (
|
||
<Boundary
|
||
fallback={
|
||
// Wrapped in the chart-card shell so a chunk failure renders
|
||
// as one solid card among the other cards, not a bare dashed
|
||
// box; `role="alert"` because the whole chart is gone, not
|
||
// just its data.
|
||
<div
|
||
data-slot="chart-error-boundary-card"
|
||
className="bg-card border-border rounded-xl border p-4 md:p-6"
|
||
>
|
||
<ChartErrorState
|
||
title={t("charts.loadErrorTitle")}
|
||
actionLabel={t("charts.loadErrorReload")}
|
||
onAction={() => window.location.reload()}
|
||
role="alert"
|
||
/>
|
||
</div>
|
||
}
|
||
>
|
||
{children}
|
||
</Boundary>
|
||
);
|
||
}
|