- Replace compact dot+number scores with 64px ScoreGauge circles - ScoreGauge scales font/stroke/spacing for small sizes - Add "Page Load Timeline" header to filmstrip section - Replace pill toggle with animated underline tabs (matches dashboard)
696 lines
30 KiB
TypeScript
696 lines
30 KiB
TypeScript
'use client'
|
|
|
|
import { useAuth } from '@/lib/auth/context'
|
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
|
import { useParams } from 'next/navigation'
|
|
import { useSite, usePageSpeedConfig, usePageSpeedLatest, usePageSpeedHistory } from '@/lib/swr/dashboard'
|
|
import { updatePageSpeedConfig, triggerPageSpeedCheck, getPageSpeedLatest, type PageSpeedCheck, type AuditSummary } from '@/lib/api/pagespeed'
|
|
import { toast, Button } from '@ciphera-net/ui'
|
|
import { motion } from 'framer-motion'
|
|
import ScoreGauge from '@/components/pagespeed/ScoreGauge'
|
|
import {
|
|
AreaChart,
|
|
Area,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
ReferenceLine,
|
|
} from 'recharts'
|
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/charts'
|
|
|
|
// * Chart configuration for score trend
|
|
const chartConfig = {
|
|
score: { label: 'Performance', color: 'var(--chart-1)' },
|
|
} satisfies ChartConfig
|
|
|
|
// * Metric status thresholds (Google's Core Web Vitals thresholds)
|
|
function getMetricStatus(metric: string, value: number | null): { label: string; color: string } {
|
|
if (value === null) return { label: '--', color: 'text-neutral-400' }
|
|
const thresholds: Record<string, [number, number]> = {
|
|
lcp: [2500, 4000],
|
|
cls: [0.1, 0.25],
|
|
tbt: [200, 600],
|
|
fcp: [1800, 3000],
|
|
si: [3400, 5800],
|
|
tti: [3800, 7300],
|
|
}
|
|
const [good, poor] = thresholds[metric] ?? [0, 0]
|
|
if (value <= good) return { label: 'Good', color: 'text-emerald-600 dark:text-emerald-400' }
|
|
if (value <= poor) return { label: 'Needs Improvement', color: 'text-amber-600 dark:text-amber-400' }
|
|
return { label: 'Poor', color: 'text-red-600 dark:text-red-400' }
|
|
}
|
|
|
|
// * Format metric values for display
|
|
function formatMetricValue(metric: string, value: number | null): string {
|
|
if (value === null) return '--'
|
|
if (metric === 'cls') return value.toFixed(3)
|
|
if (value < 1000) return `${value}ms`
|
|
return `${(value / 1000).toFixed(1)}s`
|
|
}
|
|
|
|
// * Format time ago for last checked display
|
|
function formatTimeAgo(dateString: string | null): string {
|
|
if (!dateString) return 'Never'
|
|
const date = new Date(dateString)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - date.getTime()
|
|
const diffSec = Math.floor(diffMs / 1000)
|
|
|
|
if (diffSec < 60) return 'just now'
|
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
|
|
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
|
|
return `${Math.floor(diffSec / 86400)}d ago`
|
|
}
|
|
|
|
// * Get dot color for audit items based on score
|
|
function getAuditDotColor(score: number | null): string {
|
|
if (score === null) return 'bg-neutral-400'
|
|
if (score >= 0.9) return 'bg-emerald-500'
|
|
if (score >= 0.5) return 'bg-amber-500'
|
|
return 'bg-red-500'
|
|
}
|
|
|
|
// * Main PageSpeed page
|
|
export default function PageSpeedPage() {
|
|
const { user } = useAuth()
|
|
const canEdit = user?.role === 'owner' || user?.role === 'admin'
|
|
const params = useParams()
|
|
const siteId = params.id as string
|
|
|
|
const { data: site } = useSite(siteId)
|
|
const { data: config, mutate: mutateConfig } = usePageSpeedConfig(siteId)
|
|
const { data: latestChecks, isLoading, mutate: mutateLatest } = usePageSpeedLatest(siteId)
|
|
|
|
const [strategy, setStrategy] = useState<'mobile' | 'desktop'>('mobile')
|
|
const [running, setRunning] = useState(false)
|
|
const [toggling, setToggling] = useState(false)
|
|
const [frequency, setFrequency] = useState<string>('weekly')
|
|
|
|
const { data: historyChecks } = usePageSpeedHistory(siteId, strategy)
|
|
|
|
// * Get the check for the current strategy
|
|
const currentCheck = latestChecks?.find(c => c.strategy === strategy) ?? null
|
|
|
|
// * Set document title
|
|
useEffect(() => {
|
|
if (site?.domain) document.title = `PageSpeed · ${site.domain} | Pulse`
|
|
}, [site?.domain])
|
|
|
|
// * Sync frequency from config when loaded
|
|
useEffect(() => {
|
|
if (config?.frequency) setFrequency(config.frequency)
|
|
}, [config?.frequency])
|
|
|
|
// * Toggle PageSpeed monitoring on/off
|
|
const handleToggle = async (enabled: boolean) => {
|
|
setToggling(true)
|
|
try {
|
|
await updatePageSpeedConfig(siteId, { enabled, frequency })
|
|
mutateConfig()
|
|
mutateLatest()
|
|
toast.success(enabled ? 'PageSpeed monitoring enabled' : 'PageSpeed monitoring disabled')
|
|
} catch {
|
|
toast.error('Failed to update PageSpeed monitoring')
|
|
} finally {
|
|
setToggling(false)
|
|
}
|
|
}
|
|
|
|
// * Trigger a manual PageSpeed check
|
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
const stopPolling = useCallback(() => {
|
|
if (pollRef.current) {
|
|
clearInterval(pollRef.current)
|
|
pollRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => () => stopPolling(), [stopPolling])
|
|
|
|
const handleRunCheck = async () => {
|
|
setRunning(true)
|
|
try {
|
|
await triggerPageSpeedCheck(siteId)
|
|
toast.success('PageSpeed check started — results will appear in 30-60 seconds')
|
|
|
|
// * Poll silently without triggering SWR re-renders.
|
|
// * Fetch latest directly and only update SWR cache once when new data arrives.
|
|
const initialCheckedAt = latestChecks?.[0]?.checked_at
|
|
const startedAt = Date.now()
|
|
|
|
stopPolling()
|
|
pollRef.current = setInterval(async () => {
|
|
if (Date.now() - startedAt > 120_000) {
|
|
stopPolling()
|
|
setRunning(false)
|
|
toast.error('Check is taking longer than expected. Results will appear when ready.')
|
|
return
|
|
}
|
|
try {
|
|
const fresh = await getPageSpeedLatest(siteId)
|
|
if (fresh?.[0]?.checked_at && fresh[0].checked_at !== initialCheckedAt) {
|
|
stopPolling()
|
|
setRunning(false)
|
|
mutateLatest() // * Single SWR revalidation when new data is ready
|
|
toast.success('PageSpeed check complete')
|
|
}
|
|
} catch {
|
|
// * Silent — keep polling
|
|
}
|
|
}, 5000)
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'Failed to start check')
|
|
setRunning(false)
|
|
}
|
|
}
|
|
|
|
// * Loading state
|
|
if (isLoading && !latestChecks) return <PageSpeedSkeleton />
|
|
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
|
|
|
|
const enabled = config?.enabled ?? false
|
|
|
|
// * Disabled state — show empty state with enable toggle
|
|
if (!enabled) {
|
|
return (
|
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
|
|
PageSpeed
|
|
</h1>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
Monitor your site's performance and Core Web Vitals
|
|
</p>
|
|
</div>
|
|
|
|
{/* Empty state */}
|
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center">
|
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4 w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
|
<svg className="w-8 h-8 text-neutral-500 dark:text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="font-semibold text-neutral-900 dark:text-white mb-2">
|
|
PageSpeed monitoring is disabled
|
|
</h3>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6 max-w-md mx-auto">
|
|
Enable PageSpeed monitoring to track your site's performance scores, Core Web Vitals, and get actionable improvement suggestions.
|
|
</p>
|
|
|
|
{/* Frequency selector */}
|
|
<div className="flex items-center justify-center gap-3 mb-6">
|
|
<label className="text-sm text-neutral-600 dark:text-neutral-400">Check frequency:</label>
|
|
<select
|
|
value={frequency}
|
|
onChange={(e) => setFrequency(e.target.value)}
|
|
className="text-sm border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-neutral-900 dark:focus:ring-neutral-100"
|
|
>
|
|
<option value="daily">Daily</option>
|
|
<option value="weekly">Weekly</option>
|
|
<option value="monthly">Monthly</option>
|
|
</select>
|
|
</div>
|
|
|
|
{canEdit && (
|
|
<Button
|
|
onClick={() => handleToggle(true)}
|
|
disabled={toggling}
|
|
>
|
|
{toggling ? 'Enabling...' : 'Enable PageSpeed Monitoring'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// * Prepare chart data from history
|
|
const chartData = (historyChecks ?? []).map(c => ({
|
|
date: new Date(c.checked_at).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }),
|
|
score: c.performance_score,
|
|
}))
|
|
// * Check if all chart labels are the same (single day of data)
|
|
const uniqueDates = new Set(chartData.map(d => d.date))
|
|
const hideXAxis = uniqueDates.size <= 1
|
|
|
|
// * Parse audits into groups by Lighthouse category
|
|
const audits = currentCheck?.audits ?? []
|
|
const passed = audits.filter(a => a.category === 'passed')
|
|
|
|
const categoryGroups = [
|
|
{ key: 'performance', label: 'Performance' },
|
|
{ key: 'accessibility', label: 'Accessibility' },
|
|
{ key: 'best-practices', label: 'Best Practices' },
|
|
{ key: 'seo', label: 'SEO' },
|
|
]
|
|
|
|
// * Build per-category failing audits, sorted by impact
|
|
const auditsByGroup: Record<string, typeof audits> = {}
|
|
for (const group of categoryGroups) {
|
|
auditsByGroup[group.key] = audits
|
|
.filter(a => a.category !== 'passed' && a.group === group.key)
|
|
.sort((a, b) => {
|
|
if (a.category === 'opportunity' && b.category !== 'opportunity') return -1
|
|
if (a.category !== 'opportunity' && b.category === 'opportunity') return 1
|
|
if (a.category === 'opportunity' && b.category === 'opportunity') {
|
|
return (b.savings_ms ?? 0) - (a.savings_ms ?? 0)
|
|
}
|
|
return 0
|
|
})
|
|
}
|
|
|
|
// * Core Web Vitals metrics
|
|
const metrics = [
|
|
{ key: 'fcp', label: 'First Contentful Paint', value: currentCheck?.fcp_ms ?? null },
|
|
{ key: 'lcp', label: 'Largest Contentful Paint', value: currentCheck?.lcp_ms ?? null },
|
|
{ key: 'tbt', label: 'Total Blocking Time', value: currentCheck?.tbt_ms ?? null },
|
|
{ key: 'cls', label: 'Cumulative Layout Shift', value: currentCheck?.cls ?? null },
|
|
{ key: 'si', label: 'Speed Index', value: currentCheck?.si_ms ?? null },
|
|
{ key: 'tti', label: 'Time to Interactive', value: currentCheck?.tti_ms ?? null },
|
|
]
|
|
|
|
// * Compact score helper for the hero section
|
|
const compactScores = [
|
|
{ label: 'Accessibility', score: currentCheck?.accessibility_score ?? null },
|
|
{ label: 'Best Practices', score: currentCheck?.best_practices_score ?? null },
|
|
{ label: 'SEO', score: currentCheck?.seo_score ?? null },
|
|
]
|
|
|
|
function getScoreColor(score: number | null): string {
|
|
if (score === null) return '#6b7280'
|
|
if (score >= 90) return '#0cce6b'
|
|
if (score >= 50) return '#ffa400'
|
|
return '#ff4e42'
|
|
}
|
|
|
|
function getMetricDotColor(metric: string, value: number | null): string {
|
|
if (value === null) return 'bg-neutral-400'
|
|
const status = getMetricStatus(metric, value)
|
|
if (status.label === 'Good') return 'bg-emerald-500'
|
|
if (status.label === 'Needs Improvement') return 'bg-amber-500'
|
|
return 'bg-red-500'
|
|
}
|
|
|
|
// * Enabled state — show full PageSpeed dashboard
|
|
return (
|
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
|
{/* Header */}
|
|
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
|
|
PageSpeed
|
|
</h1>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
Performance scores and Core Web Vitals for {site.domain}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{/* Mobile / Desktop toggle */}
|
|
<div className="flex gap-1" role="tablist" aria-label="Strategy tabs">
|
|
{(['mobile', 'desktop'] as const).map(tab => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setStrategy(tab)}
|
|
role="tab"
|
|
aria-selected={strategy === tab}
|
|
className={`relative px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
|
|
strategy === tab
|
|
? 'text-white'
|
|
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-300'
|
|
}`}
|
|
>
|
|
{tab === 'mobile' ? 'Mobile' : 'Desktop'}
|
|
{strategy === tab && (
|
|
<motion.div
|
|
layoutId="pagespeedStrategyTab"
|
|
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
|
|
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
|
/>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{canEdit && (
|
|
<>
|
|
<Button
|
|
onClick={handleRunCheck}
|
|
disabled={running}
|
|
>
|
|
{running ? 'Running...' : 'Run Check'}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => handleToggle(false)}
|
|
disabled={toggling}
|
|
className="text-sm"
|
|
>
|
|
{toggling ? 'Disabling...' : 'Disable'}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section 1 — Hero Card: Score Gauge + Compact Scores + Screenshot */}
|
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 mb-6">
|
|
<div className="flex flex-col md:flex-row items-center gap-8">
|
|
{/* Left — Large Performance Gauge */}
|
|
<div className="flex-shrink-0">
|
|
<ScoreGauge score={currentCheck?.performance_score ?? null} label="Performance" size={160} />
|
|
</div>
|
|
|
|
{/* Center — Compact Scores + Meta */}
|
|
<div className="flex-1 flex flex-col justify-center gap-4 min-w-0">
|
|
<div className="flex flex-wrap gap-4">
|
|
{compactScores.map(({ label, score }) => (
|
|
<ScoreGauge key={label} score={score} label={label} size={64} />
|
|
))}
|
|
</div>
|
|
|
|
{/* Last checked + frequency */}
|
|
<div className="flex items-center gap-3 text-sm text-neutral-500 dark:text-neutral-400 mt-1">
|
|
{currentCheck?.checked_at && (
|
|
<span>Last checked {formatTimeAgo(currentCheck.checked_at)}</span>
|
|
)}
|
|
{config?.frequency && (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400">
|
|
{config.frequency}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Score Legend */}
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-neutral-400 dark:text-neutral-500">
|
|
<span className="flex items-center gap-1">
|
|
<span className="inline-block w-2 h-2 rounded-full bg-red-500" />
|
|
0–49
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<span className="inline-block w-2 h-2 rounded-full bg-amber-500" />
|
|
50–89
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<span className="inline-block w-2 h-2 rounded-full bg-emerald-500" />
|
|
90–100
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right — Screenshot */}
|
|
{currentCheck?.screenshot && (
|
|
<div className="flex-shrink-0 flex items-center justify-center">
|
|
<img
|
|
src={currentCheck.screenshot}
|
|
alt={`${strategy} screenshot`}
|
|
className="rounded-lg max-h-48 w-auto border border-neutral-200 dark:border-neutral-700 object-contain"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filmstrip — page load progression */}
|
|
{currentCheck?.filmstrip && currentCheck.filmstrip.length > 0 && (
|
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 mb-6 relative">
|
|
<h3 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-4">
|
|
Page Load Timeline
|
|
</h3>
|
|
<div className="flex items-center overflow-x-auto gap-1 scrollbar-none">
|
|
{currentCheck.filmstrip.map((frame, idx) => (
|
|
<div key={idx} className="flex-shrink-0 text-center">
|
|
<img
|
|
src={frame.data}
|
|
alt={`${frame.timing}ms`}
|
|
className="h-24 rounded border border-neutral-200 dark:border-neutral-700 object-contain bg-neutral-50 dark:bg-neutral-800"
|
|
/>
|
|
<span className="text-[10px] text-neutral-400 mt-1 block">
|
|
{frame.timing < 1000 ? `${frame.timing}ms` : `${(frame.timing / 1000).toFixed(1)}s`}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* Fade indicator for horizontal scroll */}
|
|
<div className="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-white dark:from-neutral-900 to-transparent rounded-r-2xl pointer-events-none" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Section 2 — Metrics Card */}
|
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 mb-6">
|
|
<h3 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-5">
|
|
Metrics
|
|
</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
|
|
{metrics.map(({ key, label, value }) => (
|
|
<div key={key} className="flex items-start gap-3">
|
|
<span className={`mt-1.5 inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 ${getMetricDotColor(key, value)}`} />
|
|
<div>
|
|
<div className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
{label}
|
|
</div>
|
|
<div className="text-2xl font-semibold text-neutral-900 dark:text-white tabular-nums">
|
|
{formatMetricValue(key, value)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section 3 — Score Trend Chart */}
|
|
{chartData.length >= 2 && (
|
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 mb-6">
|
|
<h3 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-4">
|
|
Performance Score Trend
|
|
</h3>
|
|
<ChartContainer config={chartConfig} className="h-40">
|
|
<AreaChart accessibilityLayer data={chartData} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
|
|
<defs>
|
|
<linearGradient id="scoreGradient" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="var(--color-score)" stopOpacity={0.3} />
|
|
<stop offset="100%" stopColor="var(--color-score)" stopOpacity={0.02} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
stroke="var(--chart-grid)"
|
|
strokeOpacity={0.5}
|
|
vertical={false}
|
|
/>
|
|
<XAxis
|
|
dataKey="date"
|
|
tick={hideXAxis ? false : { fontSize: 10, fill: 'var(--chart-axis)' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
interval="preserveStartEnd"
|
|
hide={hideXAxis}
|
|
/>
|
|
<YAxis
|
|
domain={[0, 100]}
|
|
tick={{ fontSize: 10, fill: 'var(--chart-axis)' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<ReferenceLine y={90} stroke="#0cce6b" strokeDasharray="4 4" strokeOpacity={0.6} />
|
|
<ReferenceLine y={50} stroke="#ff4e42" strokeDasharray="4 4" strokeOpacity={0.6} />
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
className="text-xs"
|
|
labelKey="date"
|
|
formatter={(value) => <span className="font-semibold">{value}</span>}
|
|
/>
|
|
}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="score"
|
|
stroke="var(--color-score)"
|
|
strokeWidth={2}
|
|
fill="url(#scoreGradient)"
|
|
dot={false}
|
|
activeDot={{ r: 4, fill: 'var(--color-score)', strokeWidth: 0 }}
|
|
/>
|
|
</AreaChart>
|
|
</ChartContainer>
|
|
</div>
|
|
)}
|
|
|
|
{/* Section 4 — Diagnostics by Category */}
|
|
{audits.length > 0 && (
|
|
<div className="space-y-6">
|
|
{categoryGroups.map(group => {
|
|
const groupAudits = auditsByGroup[group.key] ?? []
|
|
const groupPassed = passed.filter(a => a.group === group.key)
|
|
// * Hide categories with no failing audits — showing only passed count adds no value
|
|
if (groupAudits.length === 0) return null
|
|
return (
|
|
<div key={group.key} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8">
|
|
<h3 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-4">
|
|
{group.label}
|
|
</h3>
|
|
|
|
{groupAudits.length > 0 && (
|
|
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
|
{groupAudits.map(audit => <AuditRow key={audit.id} audit={audit} />)}
|
|
</div>
|
|
)}
|
|
|
|
{groupPassed.length > 0 && (
|
|
<details className="mt-4">
|
|
<summary className="cursor-pointer text-sm font-medium text-neutral-500 dark:text-neutral-400 select-none hover:text-neutral-700 dark:hover:text-neutral-300 transition-colors">
|
|
<span className="ml-1">{groupPassed.length} passed audit{groupPassed.length !== 1 ? 's' : ''}</span>
|
|
</summary>
|
|
<div className="mt-2 divide-y divide-neutral-100 dark:divide-neutral-800">
|
|
{groupPassed.map(audit => <AuditRow key={audit.id} audit={audit} />)}
|
|
</div>
|
|
</details>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// * Severity indicator based on audit score (pagespeed.web.dev style)
|
|
function AuditSeverityIcon({ score }: { score: number | null }) {
|
|
if (score === null || score < 0.5) {
|
|
// Red triangle for poor / unknown
|
|
return <span className="text-red-500 text-sm leading-none flex-shrink-0" aria-label="Poor">▲</span>
|
|
}
|
|
if (score < 0.9) {
|
|
// Amber square for needs improvement
|
|
return <span className="text-amber-500 text-sm leading-none flex-shrink-0" aria-label="Needs Improvement">■</span>
|
|
}
|
|
// Green circle for good
|
|
return <span className="text-emerald-500 text-sm leading-none flex-shrink-0" aria-label="Good">●</span>
|
|
}
|
|
|
|
// * Expandable audit row with description and detail items
|
|
function AuditRow({ audit }: { audit: AuditSummary }) {
|
|
return (
|
|
<details className="group">
|
|
<summary className="flex items-center gap-3 py-3 px-2 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer list-none">
|
|
<AuditSeverityIcon score={audit.score} />
|
|
<span className="font-medium text-sm text-neutral-900 dark:text-white flex-1 min-w-0 truncate">{audit.title}</span>
|
|
{audit.display_value && (
|
|
<span className="text-xs text-neutral-500 dark:text-neutral-500 flex-shrink-0 tabular-nums">{audit.display_value}</span>
|
|
)}
|
|
{audit.savings_ms != null && audit.savings_ms > 0 && !audit.display_value && (
|
|
<span className="text-sm font-medium text-amber-600 dark:text-amber-400 flex-shrink-0 tabular-nums">
|
|
{audit.savings_ms < 1000 ? `${Math.round(audit.savings_ms)}ms` : `${(audit.savings_ms / 1000).toFixed(1)}s`}
|
|
</span>
|
|
)}
|
|
<svg className="w-4 h-4 text-neutral-400 transition-transform group-open:rotate-180 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</summary>
|
|
<div className="pl-8 pr-2 pb-3 pt-1">
|
|
{/* Description */}
|
|
{audit.description && (
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3 leading-relaxed">{audit.description}</p>
|
|
)}
|
|
{/* Items list */}
|
|
{audit.details && Array.isArray(audit.details) && audit.details.length > 0 && (
|
|
<div className="space-y-2">
|
|
{audit.details.slice(0, 10).map((item: Record<string, any>, idx: number) => (
|
|
<div key={idx} className="flex items-start gap-3 py-2 border-b border-neutral-100 dark:border-neutral-800 last:border-0 text-xs text-neutral-600 dark:text-neutral-400">
|
|
{/* Element screenshot */}
|
|
{item.node?.screenshot?.data && (
|
|
<img
|
|
src={item.node.screenshot.data}
|
|
alt=""
|
|
className="w-20 h-14 object-contain rounded border border-neutral-200 dark:border-neutral-700 flex-shrink-0 bg-neutral-50 dark:bg-neutral-800"
|
|
/>
|
|
)}
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
{/* Label / node explanation */}
|
|
{(item.node?.nodeLabel || item.label || item.groupLabel) && (
|
|
<div className="font-medium text-neutral-900 dark:text-white text-xs mb-0.5">
|
|
{item.node?.nodeLabel || item.label || item.groupLabel}
|
|
</div>
|
|
)}
|
|
{/* URL */}
|
|
{item.url && (
|
|
<div className="font-mono text-xs text-neutral-500 dark:text-neutral-400 break-all">{item.url}</div>
|
|
)}
|
|
{/* HTML snippet */}
|
|
{item.node?.snippet && (
|
|
<code className="text-xs bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded break-all mt-1 inline-block">{item.node.snippet}</code>
|
|
)}
|
|
{/* Statistic-type items */}
|
|
{!item.url && !item.node && item.statistic && (
|
|
<span>{item.statistic}</span>
|
|
)}
|
|
</div>
|
|
{/* Metrics on the right */}
|
|
<div className="flex-shrink-0 text-right space-y-0.5">
|
|
{item.wastedBytes != null && (
|
|
<div className="text-amber-600 dark:text-amber-400 whitespace-nowrap">
|
|
{item.wastedBytes < 1024 ? `${item.wastedBytes} B` : `${(item.wastedBytes / 1024).toFixed(1)} KiB`}
|
|
</div>
|
|
)}
|
|
{item.totalBytes != null && !item.wastedBytes && (
|
|
<div className="whitespace-nowrap">
|
|
{item.totalBytes < 1024 ? `${item.totalBytes} B` : `${(item.totalBytes / 1024).toFixed(1)} KiB`}
|
|
</div>
|
|
)}
|
|
{item.wastedMs != null && (
|
|
<div className="text-amber-600 dark:text-amber-400 whitespace-nowrap">
|
|
{item.wastedMs < 1000 ? `${Math.round(item.wastedMs)}ms` : `${(item.wastedMs / 1000).toFixed(1)}s`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{audit.details.length > 10 && (
|
|
<p className="text-xs text-neutral-400 mt-1">+ {audit.details.length - 10} more items</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</details>
|
|
)
|
|
}
|
|
|
|
// * Skeleton loading state
|
|
function PageSpeedSkeleton() {
|
|
return (
|
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 space-y-6">
|
|
<div className="animate-pulse space-y-2 mb-8">
|
|
<div className="h-8 w-48 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
|
<div className="h-4 w-72 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
|
</div>
|
|
{/* Hero skeleton */}
|
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 animate-pulse">
|
|
<div className="flex items-center gap-8">
|
|
<div className="w-40 h-40 rounded-full bg-neutral-200 dark:bg-neutral-700 flex-shrink-0" />
|
|
<div className="flex-1 space-y-3">
|
|
<div className="h-5 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
|
<div className="h-5 w-36 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
|
<div className="h-5 w-24 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
|
</div>
|
|
<div className="w-48 h-36 bg-neutral-200 dark:bg-neutral-700 rounded-lg flex-shrink-0 hidden md:block" />
|
|
</div>
|
|
</div>
|
|
{/* Metrics skeleton */}
|
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 animate-pulse">
|
|
<div className="h-3 w-16 bg-neutral-200 dark:bg-neutral-700 rounded mb-5" />
|
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
|
|
{[...Array(6)].map((_, i) => (
|
|
<div key={i} className="space-y-2">
|
|
<div className="h-3 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
|
<div className="h-7 w-20 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|