'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, type PageSpeedCheck, type AuditSummary } from '@/lib/api/pagespeed' import { toast, Button } from '@ciphera-net/ui' 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 = { 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('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 // * Poll for results after triggering an async check const pollRef = useRef | null>(null) const stopPolling = useCallback(() => { if (pollRef.current) { clearInterval(pollRef.current) pollRef.current = null } }, []) // * Clean up polling on unmount 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 every 5s for up to 2 minutes until new results appear const startedAt = Date.now() const initialCheckedAt = latestChecks?.[0]?.checked_at stopPolling() pollRef.current = setInterval(async () => { const elapsed = Date.now() - startedAt if (elapsed > 120_000) { stopPolling() setRunning(false) toast.error('Check is taking longer than expected. Results will appear when ready.') return } const freshData = await mutateLatest() const freshCheckedAt = freshData?.[0]?.checked_at if (freshCheckedAt && freshCheckedAt !== initialCheckedAt) { stopPolling() setRunning(false) toast.success('PageSpeed check complete') } }, 5000) } catch (err: any) { toast.error(err?.message || 'Failed to start check') setRunning(false) } } // * Loading state if (isLoading && !latestChecks) return if (!site) return
Site not found
const enabled = config?.enabled ?? false // * Disabled state — show empty state with enable toggle if (!enabled) { return (
{/* Header */}

PageSpeed

Monitor your site's performance and Core Web Vitals

{/* Empty state */}

PageSpeed monitoring is disabled

Enable PageSpeed monitoring to track your site's performance scores, Core Web Vitals, and get actionable improvement suggestions.

{/* Frequency selector */}
{canEdit && ( )}
) } // * 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, })) // * 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 = {} 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 (
{/* Header */}

PageSpeed

Performance scores and Core Web Vitals for {site.domain}

{/* Mobile / Desktop toggle */}
{canEdit && ( <> )}
{/* Section 1 — Hero Card: Score Gauge + Compact Scores + Screenshot */}
{/* Left — Large Performance Gauge */}
{/* Center — Compact Scores + Meta */}
{compactScores.map(({ label, score }) => (
{score !== null ? Math.round(score) : '--'} {label}
))}
{/* Last checked + frequency */}
{currentCheck?.checked_at && ( Last checked {formatTimeAgo(currentCheck.checked_at)} )} {config?.frequency && ( {config.frequency} )}
{/* Score Legend */}
0–49 Poor 50–89 Needs Improvement 90–100 Good
{/* Right — Screenshot */} {currentCheck?.screenshot && (
{`${strategy}
)}
{/* Filmstrip — page load progression */} {currentCheck?.filmstrip && currentCheck.filmstrip.length > 0 && (
{currentCheck.filmstrip.map((frame, idx) => (
{`${frame.timing}ms`} {frame.timing < 1000 ? `${frame.timing}ms` : `${(frame.timing / 1000).toFixed(1)}s`}
))}
)} {/* Section 2 — Metrics Card */}

Metrics

{metrics.map(({ key, label, value }) => (
{label}
{formatMetricValue(key, value)}
))}
{/* Section 3 — Score Trend Chart */} {chartData.length >= 2 && (

Performance Score Trend

{value}} /> } />
)} {/* Section 4 — Diagnostics by Category */} {audits.length > 0 && (
{categoryGroups.map(group => { const groupAudits = auditsByGroup[group.key] ?? [] const groupPassed = passed.filter(a => a.group === group.key) if (groupAudits.length === 0 && groupPassed.length === 0) return null return (

{group.label}

{groupAudits.length > 0 && (
{groupAudits.map(audit => )}
)} {groupPassed.length > 0 && (
{groupPassed.length} passed audit{groupPassed.length !== 1 ? 's' : ''}
{groupPassed.map(audit => )}
)}
) })}
)}
) } // * 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 } if (score < 0.9) { // Amber square for needs improvement return } // Green circle for good return } // * Expandable audit row with description and detail items function AuditRow({ audit }: { audit: AuditSummary }) { return (
{audit.title} {audit.display_value && ( {audit.display_value} )} {audit.savings_ms != null && audit.savings_ms > 0 && !audit.display_value && ( {audit.savings_ms < 1000 ? `${Math.round(audit.savings_ms)}ms` : `${(audit.savings_ms / 1000).toFixed(1)}s`} )}
{/* Description */} {audit.description && (

{audit.description}

)} {/* Items list */} {audit.details && Array.isArray(audit.details) && audit.details.length > 0 && (
{audit.details.slice(0, 10).map((item: Record, idx: number) => (
{/* Element screenshot */} {item.node?.screenshot?.data && ( )} {/* Content */}
{/* Label / node explanation */} {(item.node?.nodeLabel || item.label || item.groupLabel) && (
{item.node?.nodeLabel || item.label || item.groupLabel}
)} {/* URL */} {item.url && (
{item.url}
)} {/* HTML snippet */} {item.node?.snippet && ( {item.node.snippet} )} {/* Statistic-type items */} {!item.url && !item.node && item.statistic && ( {item.statistic} )}
{/* Metrics on the right */}
{item.wastedBytes != null && (
{item.wastedBytes < 1024 ? `${item.wastedBytes} B` : `${(item.wastedBytes / 1024).toFixed(1)} KiB`}
)} {item.totalBytes != null && !item.wastedBytes && (
{item.totalBytes < 1024 ? `${item.totalBytes} B` : `${(item.totalBytes / 1024).toFixed(1)} KiB`}
)} {item.wastedMs != null && (
{item.wastedMs < 1000 ? `${Math.round(item.wastedMs)}ms` : `${(item.wastedMs / 1000).toFixed(1)}s`}
)}
))} {audit.details.length > 10 && (

+ {audit.details.length - 10} more items

)}
)}
) } // * Skeleton loading state function PageSpeedSkeleton() { return (
{[...Array(4)].map((_, i) => (
))}
{[...Array(6)].map((_, i) => (
))}
) }