Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 30 additions & 15 deletions src/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect } from "react";
import { useEffect, useRef } from "react";

/**
* Last-resort boundary for the App Router. Unlike `app/error.tsx`, this replaces
Expand All @@ -11,8 +11,10 @@ import { useEffect } from "react";
* still renders correctly when the styling/theming system is exactly what failed.
*/
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
console.error("Fatal error captured by global-error boundary:", error);
headingRef.current?.focus();
}, [error]);

return (
Expand All @@ -25,8 +27,8 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
alignItems: "center",
justifyContent: "center",
padding: "1rem",
backgroundColor: "#f4f5f7",
color: "#1a1c1e",
backgroundColor: "Canvas",
color: "CanvasText",
fontFamily:
'system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"',
}}
Expand All @@ -37,27 +39,40 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
maxWidth: "28rem",
boxSizing: "border-box",
borderRadius: "1rem",
border: "1px solid #e2e4e8",
backgroundColor: "#ffffff",
border: "1px solid CanvasText",
backgroundColor: "Canvas",
padding: "1.5rem",
textAlign: "center",
boxShadow: "0 10px 30px rgba(15, 23, 42, 0.12)",
boxShadow: "0 10px 30px color-mix(in srgb, CanvasText 12%, transparent)",
}}
>
<h1 style={{ margin: "0 0 0.5rem", fontSize: "1.125rem", fontWeight: 600 }}>Something went wrong</h1>
<p style={{ margin: "0 0 1.25rem", fontSize: "0.875rem", lineHeight: 1.6, color: "#5b6069" }}>
<h1
ref={headingRef}
tabIndex={-1}
role="alert"
style={{
margin: "0 0 0.5rem",
fontSize: "1.125rem",
fontWeight: 600,
outline: "2px solid Highlight",
outlineOffset: "2px",
}}
>
Something went wrong
</h1>
<p style={{ margin: "0 0 1.25rem", fontSize: "0.875rem", lineHeight: 1.6 }}>
The application failed to load. Please try again, or reload the page if the problem persists.
</p>
{error.digest && (
<div
style={{
margin: "0 0 1.25rem",
borderRadius: "0.5rem",
backgroundColor: "#f4f5f7",
backgroundColor: "Canvas",
padding: "0.5rem",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
fontSize: "0.75rem",
color: "#5b6069",
color: "CanvasText",
wordBreak: "break-all",
}}
>
Expand All @@ -72,8 +87,8 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
cursor: "pointer",
borderRadius: "0.5rem",
border: "none",
backgroundColor: "#1a1c1e",
color: "#ffffff",
backgroundColor: "ButtonFace",
color: "ButtonText",
padding: "0.625rem 1rem",
fontSize: "0.875rem",
fontWeight: 600,
Expand All @@ -87,9 +102,9 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
style={{
cursor: "pointer",
borderRadius: "0.5rem",
border: "1px solid #e2e4e8",
backgroundColor: "#ffffff",
color: "#1a1c1e",
border: "1px solid ButtonText",
backgroundColor: "ButtonFace",
color: "ButtonText",
padding: "0.625rem 1rem",
fontSize: "0.875rem",
fontWeight: 600,
Expand Down
12 changes: 10 additions & 2 deletions src/components/route-error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { TriangleAlert, RefreshCw } from "lucide-react";

import { cn, primaryControl } from "@/components/ui-primitives";
Expand Down Expand Up @@ -38,8 +38,10 @@ export function RouteErrorBoundary({
showReload = false,
minHeightClass = "min-h-[50vh]",
}: RouteErrorBoundaryProps) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
console.error(logLabel, error);
headingRef.current?.focus();
}, [error, logLabel]);

return (
Expand All @@ -54,7 +56,13 @@ export function RouteErrorBoundary({
<TriangleAlert aria-hidden="true" className="h-6 w-6" />
</div>

<h1 className="mt-4 text-lg font-semibold tracking-tight text-[color:var(--text-heading)]">{title}</h1>
<h1
ref={headingRef}
tabIndex={-1}
className="mt-4 text-lg font-semibold tracking-tight text-[color:var(--text-heading)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus-ring,Highlight)]"
>
{title}
</h1>

<p className="mt-2 text-sm leading-relaxed text-[color:var(--text-muted)]">{description}</p>

Expand Down
5 changes: 4 additions & 1 deletion src/components/therapy-compass/bindings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type TcBindings = {
// ---- data -----------------------------------------------------------
loading: boolean;
error: string | null;
retryData: () => void;
therapies: Therapy[];
unreviewedTherapies: Therapy[];
reviewCount: number;
Expand Down Expand Up @@ -207,7 +208,7 @@ export function TcProvider({
initialQuery?: string;
autoRunSearch?: boolean;
}) {
const { data, loading, error } = useTherapyData();
const { data, loading, error, retry } = useTherapyData();
const therapies = useMemo(() => data?.therapies ?? [], [data]);
const pathways = useMemo(() => data?.pathways ?? [], [data]);

Expand Down Expand Up @@ -271,6 +272,7 @@ export function TcProvider({
return {
loading,
error,
retryData: retry,
therapies,
unreviewedTherapies,
reviewCount: unreviewedTherapies.length,
Expand Down Expand Up @@ -429,6 +431,7 @@ export function TcProvider({
}, [
loading,
error,
retry,
data,
therapies,
unreviewedTherapies,
Expand Down
25 changes: 18 additions & 7 deletions src/components/therapy-compass/data/use-therapy-data.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";

import type { Pathway, ReferenceData, Therapy, TherapyDataset } from "./types";

Expand Down Expand Up @@ -32,28 +32,39 @@ export type TherapyDataState = {
data: TherapyDataset | null;
loading: boolean;
error: string | null;
retry: () => void;
};

export function useTherapyData(): TherapyDataState {
const [state, setState] = useState<TherapyDataState>({ data: null, loading: true, error: null });
const [state, setState] = useState<Omit<TherapyDataState, "retry">>({ data: null, loading: true, error: null });
const [attempt, setAttempt] = useState(0);
const retry = useCallback(() => {
cache = null;
// Keep the prior error message mounted so Retry focus is not lost while the
// next attempt is in flight; success/failure handlers replace it below.
setState((prev) => ({ ...prev, loading: true }));
setAttempt((value) => value + 1);
}, []);

useEffect(() => {
let active = true;
cache ??= loadDataset();
cache
const request = cache;
request
.then((data) => {
if (active) setState({ data, loading: false, error: null });
})
.catch((err: unknown) => {
// Allow a retry on the next mount if the fetch failed.
cache = null;
// Only clear the shared cache when this request is still the active one.
// A newer retry may have already replaced `cache` with a fresh promise.
if (cache === request) cache = null;
if (active)
setState({ data: null, loading: false, error: err instanceof Error ? err.message : "Failed to load" });
});
return () => {
active = false;
};
}, []);
}, [attempt]);

return state;
return { ...state, retry };
}
6 changes: 4 additions & 2 deletions src/components/therapy-compass/therapy-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,11 @@ export function ResultCard({ therapy }: { therapy: Therapy }) {
<button
type="button"
className="tc-btn"
title="Favourite"
disabled
title="Favourites are not available yet"
aria-label="Favourites are coming soon"
style={s(
`display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;border:1px solid var(--border);border-radius:9px;background:var(--surface);color:var(--text-soft);cursor:pointer;`,
`display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;border:1px solid var(--border);border-radius:9px;background:var(--surface);color:var(--text-soft);cursor:not-allowed;opacity:.65;`,
)}
>
<HeartIcon size={17} />
Expand Down
50 changes: 41 additions & 9 deletions src/components/therapy-compass/therapy-compass-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,47 @@ function TherapyCompassShell() {
>
<TherapyCompassNav />
<main className="tc-main" style={s(`min-width:0;padding:32px 40px 40px;`)}>
{b.isHome && <HomeScreen />}
{b.isSearch && <SearchScreen />}
{b.isDetail && <DetailScreen />}
{b.isCompare && <CompareScreen />}
{b.isRecommend && <RecommendScreen />}
{b.isPathways && <PathwaysScreen />}
{b.isBrief && <BriefScreen />}
{b.isSheets && <SheetsScreen />}
{b.isOther && <OtherScreen />}
{b.error ? (
<section
role="alert"
aria-live="assertive"
aria-busy={b.loading}
style={s(
`max-width:42rem;margin:40px auto;padding:24px;border:1px solid var(--danger);border-radius:16px;background:var(--danger-soft);`,
)}
>
<h1 style={s(`margin:0 0 8px;color:var(--text-heading);font-size:20px;`)}>
Therapy Compass could not load
</h1>
<p style={s(`margin:0 0 16px;color:var(--text-muted);line-height:1.5;`)}>
The therapy catalogue is unavailable. No results are being shown as a substitute.
</p>
<button
type="button"
className="tc-btn"
onClick={b.retryData}
disabled={b.loading}
aria-disabled={b.loading}
style={s(
`padding:10px 14px;border:0;border-radius:8px;background:var(--clinical-accent);color:var(--clinical-accent-contrast);font-weight:650;opacity:${b.loading ? "0.7" : "1"};`,
)}
>
{b.loading ? "Retrying…" : "Retry"}
</button>
</section>
) : (
<>
{b.isHome && <HomeScreen />}
{b.isSearch && <SearchScreen />}
{b.isDetail && <DetailScreen />}
{b.isCompare && <CompareScreen />}
{b.isRecommend && <RecommendScreen />}
{b.isPathways && <PathwaysScreen />}
{b.isBrief && <BriefScreen />}
{b.isSheets && <SheetsScreen />}
{b.isOther && <OtherScreen />}
</>
)}
<TherapyCompassFooter />
</main>
</div>
Expand Down
Loading