'use client' /** * ErrorTrendChart - #43 錯誤趨勢圖表 * =================================== * Phase 10: Sentry + OpenClaw + UI 整合 * * Nothing.tech 視覺規範: * - 純白底色 (bg-white) * - 極細淺灰邊框 (border border-gray-200) * - 無圓角或微圓角 (rounded-sm) * - 嚴禁陰影 (shadow-none) * * 建立: 2026-03-26 (台北時區) * 建立者: Claude Code (#43 Error UI) */ import { useMemo } from 'react' import { useTranslations } from 'next-intl' import { cn } from '@/lib/utils' import { TrendingUp, TrendingDown, Minus, Activity } from 'lucide-react' import type { ErrorTrendResponse, ErrorTrendPoint } from '@/lib/api-client' // ============================================================================= // Component Props // ============================================================================= interface ErrorTrendChartProps { data: ErrorTrendResponse | null loading?: boolean error?: string | null onPeriodChange?: (period: '24h' | '7d' | '30d') => void activePeriod?: '24h' | '7d' | '30d' className?: string } // ============================================================================= // Sparkline Component // ============================================================================= function Sparkline({ data, width = 200, height = 60, }: { data: ErrorTrendPoint[] width?: number height?: number }) { const path = useMemo(() => { if (data.length === 0) return '' const maxCount = Math.max(...data.map((d) => d.count), 1) const minCount = Math.min(...data.map((d) => d.count), 0) const range = maxCount - minCount || 1 const points = data.map((d, i) => { const x = (i / (data.length - 1)) * width const y = height - ((d.count - minCount) / range) * height return `${x},${y}` }) return `M ${points.join(' L ')}` }, [data, width, height]) const areaPath = useMemo(() => { if (data.length === 0) return '' const maxCount = Math.max(...data.map((d) => d.count), 1) const minCount = Math.min(...data.map((d) => d.count), 0) const range = maxCount - minCount || 1 const points = data.map((d, i) => { const x = (i / (data.length - 1)) * width const y = height - ((d.count - minCount) / range) * height return `${x},${y}` }) return `M 0,${height} L ${points.join(' L ')} L ${width},${height} Z` }, [data, width, height]) if (data.length === 0) { return ( ) } return ( {/* Area fill */} {/* Line */} {/* Gradient definition */} ) } // ============================================================================= // Period Selector Component // ============================================================================= function PeriodSelector({ activePeriod, onChange, }: { activePeriod: '24h' | '7d' | '30d' onChange?: (period: '24h' | '7d' | '30d') => void }) { const periods: Array<{ value: '24h' | '7d' | '30d'; label: string }> = [ { value: '24h', label: '24h' }, { value: '7d', label: '7d' }, { value: '30d', label: '30d' }, ] return (
{periods.map(({ value, label }) => ( ))}
) } // ============================================================================= // Main Component // ============================================================================= export function ErrorTrendChart({ data, loading = false, error = null, onPeriodChange, activePeriod = '24h', className, }: ErrorTrendChartProps) { const t = useTranslations('errors') // Loading state if (loading) { return (
) } // Error state if (error) { return (
{error}
) } // Empty state if (!data) { return (

{t('noTrendData')}

) } // Trend indicator const TrendIcon = data.change_percent > 0 ? TrendingUp : data.change_percent < 0 ? TrendingDown : Minus const trendColor = data.change_percent > 0 ? 'text-red-500' : data.change_percent < 0 ? 'text-green-500' : 'text-gray-400' const trendBg = data.change_percent > 0 ? 'bg-red-50' : data.change_percent < 0 ? 'bg-green-50' : 'bg-gray-50' return (
{/* Header */}

{t('errorTrend')}

{/* Chart */}
{/* Footer */}
{/* Total Count */}
{data.total_count.toLocaleString()}
{t('totalErrors', { period: data.period })}
{/* Change Percent */}
{data.change_percent > 0 ? '+' : ''}{data.change_percent.toFixed(1)}%
) }