refactor(ts): convert app-wide common components to TSX (B8)

PageLoader, PageTransition, StatusBadge (top-level), MarkdownText (typed
renderInlineMarkdown, isValidElement for keys), and ErrorBoundary (typed class
component: ErrorBoundaryProps/State, getDerivedStateFromError, ErrorInfo;
generic withErrorBoundary<P> HOC). typecheck 0, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 19:52:47 -05:00
parent 609de2a946
commit 8937dc5e25
5 changed files with 44 additions and 30 deletions

View File

@ -1,27 +1,36 @@
import React from 'react'; import React, { type ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react'; import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
// ErrorBoundary Component // ErrorBoundary Component
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
class ErrorBoundary extends React.Component { interface ErrorBoundaryProps {
constructor(props) { children?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
componentStack: string | null;
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props); super(props);
this.state = { hasError: false, error: null, componentStack: null }; this.state = { hasError: false, error: null, componentStack: null };
} }
static getDerivedStateFromError(error) { static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return { hasError: true, error }; return { hasError: true, error };
} }
componentDidCatch(error, info) { componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', { console.error('ErrorBoundary caught an error:', {
error, error,
componentStack: info?.componentStack, componentStack: info?.componentStack,
}); });
this.setState({ error, componentStack: info?.componentStack }); this.setState({ error, componentStack: info?.componentStack ?? null });
} }
handleReset = () => { handleReset = () => {
@ -45,15 +54,15 @@ class ErrorBoundary extends React.Component {
<div className="mx-auto mb-6 h-16 w-16 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center"> <div className="mx-auto mb-6 h-16 w-16 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center">
<AlertTriangle className="h-8 w-8" /> <AlertTriangle className="h-8 w-8" />
</div> </div>
<h1 className="text-2xl font-bold tracking-tight text-foreground mb-2"> <h1 className="text-2xl font-bold tracking-tight text-foreground mb-2">
Something went wrong Something went wrong
</h1> </h1>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
An unexpected error occurred. You can try to recover by reloading the page or resetting this component. An unexpected error occurred. You can try to recover by reloading the page or resetting this component.
</p> </p>
{error && ( {error && (
<div className="mb-6 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-left"> <div className="mb-6 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-left">
<p className="text-xs font-semibold uppercase tracking-wider text-destructive mb-2"> <p className="text-xs font-semibold uppercase tracking-wider text-destructive mb-2">
@ -64,7 +73,7 @@ class ErrorBoundary extends React.Component {
</pre> </pre>
</div> </div>
)} )}
{componentStack && ( {componentStack && (
<div className="mb-6 rounded-lg border border-destructive/20 bg-destructive/5 p-4"> <div className="mb-6 rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wider text-destructive/70 mb-2"> <p className="text-[10px] font-semibold uppercase tracking-wider text-destructive/70 mb-2">
@ -75,7 +84,7 @@ class ErrorBoundary extends React.Component {
</pre> </pre>
</div> </div>
)} )}
<div className="flex flex-wrap items-center justify-center gap-3"> <div className="flex flex-wrap items-center justify-center gap-3">
<Button <Button
variant="default" variant="default"
@ -100,17 +109,20 @@ class ErrorBoundary extends React.Component {
} }
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
//withErrorBoundary HOC // withErrorBoundary HOC
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
export function withErrorBoundary(Component, displayName = Component.name || 'Component') { export function withErrorBoundary<P extends object>(
function WrappedComponent(props) { Component: React.ComponentType<P>,
displayName: string = Component.displayName || Component.name || 'Component',
) {
function WrappedComponent(props: P) {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<Component {...props} /> <Component {...props} />
</ErrorBoundary> </ErrorBoundary>
); );
} }
WrappedComponent.displayName = `withErrorBoundary(${displayName})`; WrappedComponent.displayName = `withErrorBoundary(${displayName})`;
return WrappedComponent; return WrappedComponent;
} }

View File

@ -1,8 +1,8 @@
import { Fragment } from 'react'; import { Fragment, isValidElement, type ReactNode } from 'react';
const TOKEN_RE = /(\*\*[^*\n][\s\S]*?[^*\n]\*\*|`[^`\n]+`|\[[^\]\n]+\]\(https?:\/\/[^)\s]+\))/g; const TOKEN_RE = /(\*\*[^*\n][\s\S]*?[^*\n]\*\*|`[^`\n]+`|\[[^\]\n]+\]\(https?:\/\/[^)\s]+\))/g;
function renderToken(token, key) { function renderToken(token: string, key: string): ReactNode {
if (token.startsWith('**') && token.endsWith('**')) { if (token.startsWith('**') && token.endsWith('**')) {
return ( return (
<strong key={key} className="font-semibold text-foreground"> <strong key={key} className="font-semibold text-foreground">
@ -37,18 +37,19 @@ function renderToken(token, key) {
return token; return token;
} }
export function renderInlineMarkdown(text) { export function renderInlineMarkdown(text: string | null | undefined): ReactNode {
if (!text) return null; if (!text) return null;
const parts = []; const parts: ReactNode[] = [];
let lastIndex = 0; let lastIndex = 0;
for (const match of text.matchAll(TOKEN_RE)) { for (const match of text.matchAll(TOKEN_RE)) {
if (match.index > lastIndex) { const mi = match.index ?? 0;
parts.push(text.slice(lastIndex, match.index)); if (mi > lastIndex) {
parts.push(text.slice(lastIndex, mi));
} }
parts.push(renderToken(match[0], `md-${match.index}`)); parts.push(renderToken(match[0], `md-${mi}`));
lastIndex = match.index + match[0].length; lastIndex = mi + match[0].length;
} }
if (lastIndex < text.length) { if (lastIndex < text.length) {
@ -56,12 +57,12 @@ export function renderInlineMarkdown(text) {
} }
return parts.map((part, index) => ( return parts.map((part, index) => (
<Fragment key={typeof part === 'string' ? `text-${index}` : part.key || index}> <Fragment key={isValidElement(part) ? (part.key ?? index) : `text-${index}`}>
{part} {part}
</Fragment> </Fragment>
)); ));
} }
export function MarkdownText({ text }) { export function MarkdownText({ text }: { text?: string | null }): ReactNode {
return renderInlineMarkdown(text); return renderInlineMarkdown(text);
} }

View File

@ -6,4 +6,4 @@ export default function PageLoader() {
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
</div> </div>
); );
} }

View File

@ -1,6 +1,7 @@
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import type { ReactNode } from 'react';
export default function PageTransition({ children, routeKey }) { export default function PageTransition({ children, routeKey }: { children: ReactNode; routeKey?: string }) {
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
if (reduceMotion) return children; if (reduceMotion) return children;

View File

@ -12,8 +12,8 @@ const STATUS_META = {
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' }, skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
}; };
export const StatusBadge = React.memo(function StatusBadge({ status }) { export const StatusBadge = React.memo(function StatusBadge({ status }: { status: string }) {
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]); const meta = useMemo(() => STATUS_META[status as keyof typeof STATUS_META] || STATUS_META.upcoming, [status]);
const isUrgent = status === 'late' || status === 'missed'; const isUrgent = status === 'late' || status === 'missed';
return ( return (
<span className={cn( <span className={cn(