'use client' import { useAuth } from '@/lib/auth/context' import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { useParams } from 'next/navigation' import { useSite, usePageSpeedConfig, usePageSpeedLatest, usePageSpeedHistory } from '@/lib/swr/dashboard' import { updatePageSpeedConfig, triggerPageSpeedCheck, getPageSpeedLatest, getPageSpeedCheck, 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 { remapLearnUrl } from '@/lib/learn-links' import { AreaChart as VisxAreaChart, Area as VisxArea, Grid as VisxGrid, XAxis as VisxXAxis, YAxis as VisxYAxis, ChartTooltip as VisxChartTooltip } from '@/components/ui/area-chart' import { useMinimumLoading, useSkeletonFade } from '@/components/skeletons' // * 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) // * Check history navigation — build unique check timestamps from history data const [selectedCheckId, setSelectedCheckId] = useState(null) const [selectedCheckData, setSelectedCheckData] = useState(null) const [loadingCheck, setLoadingCheck] = useState(false) // * Build unique check timestamps (each check has mobile+desktop at the same time) const checkTimestamps = useMemo(() => { if (!historyChecks?.length) return [] const seen = new Set() const timestamps: { id: string; checked_at: string }[] = [] // * History is sorted ASC by checked_at, reverse for newest first for (let i = historyChecks.length - 1; i >= 0; i--) { const c = historyChecks[i] // * Group by minute to deduplicate mobile+desktop pairs const key = c.checked_at.slice(0, 16) if (!seen.has(key)) { seen.add(key) timestamps.push({ id: c.id, checked_at: c.checked_at }) } } return timestamps }, [historyChecks]) const selectedIndex = selectedCheckId ? checkTimestamps.findIndex(t => t.id === selectedCheckId) : 0 // * 0 = latest const canGoPrev = selectedIndex < checkTimestamps.length - 1 const canGoNext = selectedIndex > 0 const handlePrevCheck = () => { if (!canGoPrev) return const next = checkTimestamps[selectedIndex + 1] setSelectedCheckId(next.id) } const handleNextCheck = () => { if (selectedIndex <= 1) { // * Going back to latest setSelectedCheckId(null) setSelectedCheckData(null) return } const next = checkTimestamps[selectedIndex - 1] setSelectedCheckId(next.id) } // * Fetch full check data when navigating to a historical check useEffect(() => { if (!selectedCheckId || !siteId) { setSelectedCheckData(null) return } let cancelled = false setLoadingCheck(true) getPageSpeedCheck(siteId, selectedCheckId).then(data => { if (!cancelled) { setSelectedCheckData(data) setLoadingCheck(false) } }).catch(() => { if (!cancelled) setLoadingCheck(false) }) return () => { cancelled = true } }, [selectedCheckId, siteId]) // * Determine which check to display — selected historical or latest const displayCheck = selectedCheckId && selectedCheckData ? selectedCheckData : latestChecks?.find(c => c.strategy === strategy) ?? null // * When viewing a historical check, we need both strategies — fetch the other one too // * For simplicity, historical view shows the selected strategy's check const currentCheck = displayCheck // * 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 | 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 with minimum display time (consistent with other pages) const showSkeleton = useMinimumLoading(isLoading && !latestChecks) const fadeClass = useSkeletonFade(showSkeleton) if (showSkeleton) 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 (visx needs Date objects for x-axis) const chartData = (historyChecks ?? []).map(c => ({ dateObj: new Date(c.checked_at), score: c.performance_score ?? 0, })) // * 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 = {} const manualByGroup: Record = {} for (const group of categoryGroups) { auditsByGroup[group.key] = audits .filter(a => a.category !== 'passed' && a.category !== 'manual' && 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 }) manualByGroup[group.key] = audits.filter(a => a.category === 'manual' && a.group === group.key) } // * 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 }, ] // * All 4 category scores for the hero row const allScores = [ { key: 'performance', label: 'Performance', score: currentCheck?.performance_score ?? null }, { key: 'accessibility', label: 'Accessibility', score: currentCheck?.accessibility_score ?? null }, { key: 'best-practices', label: 'Best Practices', score: currentCheck?.best_practices_score ?? null }, { key: 'seo', label: 'SEO', score: currentCheck?.seo_score ?? null }, ] // * Map category key to score for diagnostics section const scoreByGroup: Record = { 'performance': currentCheck?.performance_score ?? null, 'accessibility': currentCheck?.accessibility_score ?? null, 'best-practices': currentCheck?.best_practices_score ?? null, 'seo': currentCheck?.seo_score ?? null, } 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 */}
{(['mobile', 'desktop'] as const).map(tab => ( ))}
{canEdit && ( <> )}
{/* Section 1 — Score Overview: 4 equal gauges + screenshot */}
{/* 4 equal gauges — click to scroll to diagnostics */}
{allScores.map(({ key, label, score }) => ( ))}
{/* Screenshot */} {currentCheck?.screenshot && (
{`${strategy}
)}
{/* Check navigator + frequency + legend */}
{/* Prev/Next arrows */} {checkTimestamps.length > 1 && ( )} {currentCheck?.checked_at && ( {selectedCheckId ? new Date(currentCheck.checked_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : `Last checked ${formatTimeAgo(currentCheck.checked_at)}` } )} {checkTimestamps.length > 1 && ( )} {config?.frequency && ( {config.frequency} )} {loadingCheck && ( Loading... )}
0–49 50–89 90–100
{/* Filmstrip — page load progression */} {currentCheck?.filmstrip && currentCheck.filmstrip.length > 0 && (

Page Load Timeline

{currentCheck.filmstrip.map((frame, idx) => (
{`${frame.timing}ms`} {frame.timing < 1000 ? `${frame.timing}ms` : `${(frame.timing / 1000).toFixed(1)}s`}
))}
{/* Fade indicator for horizontal scroll */}
)} {/* Section 2 — Metrics Card */}

Metrics

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

Performance Score Trend

[]} xDataKey="dateObj" aspectRatio="4 / 1" margin={{ top: 10, right: 10, bottom: 30, left: 40 }} > d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} /> String(Math.round(v))} /> ) => [{ label: 'Score', value: String(Math.round(point.score as number)), color: 'var(--chart-line-primary)', }]} />
)} {/* 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) const groupManual = manualByGroup[group.key] ?? [] if (groupAudits.length === 0 && groupPassed.length === 0 && groupManual.length === 0) return null return (
{/* Category header with gauge */}

{group.label}

{(() => { const realIssues = groupAudits.filter(a => a.score !== null && a.score !== undefined).length return realIssues === 0 ? 'No issues found' : `${realIssues} issue${realIssues !== 1 ? 's' : ''} found` })()}

{groupAudits.length > 0 && ( )} {groupManual.length > 0 && (
Additional items to manually check ({groupManual.length})
{groupManual.map(audit => )}
)} {groupPassed.length > 0 && (
{groupPassed.length} passed audit{groupPassed.length !== 1 ? 's' : ''}
{groupPassed.map(audit => )}
)}
) })}
)}
) } // * Sort audits by severity: red (< 0.5) → orange (0.5-0.89) → empty (null) → green (>= 0.9) function sortBySeverity(audits: AuditSummary[]): AuditSummary[] { return [...audits].sort((a, b) => { const rank = (s: number | null | undefined) => { if (s === null || s === undefined) return 2 // empty circle if (s < 0.5) return 0 // red if (s < 0.9) return 1 // orange return 3 // green } return rank(a.score) - rank(b.score) }) } // * Known sub-group ordering: insights-type groups come before diagnostics-type groups const subGroupPriority: Record = { // * Performance 'budgets': 0, 'load-opportunities': 0, 'diagnostics': 1, // * Accessibility 'a11y-names-labels': 0, 'a11y-contrast': 1, 'a11y-best-practices': 2, 'a11y-color-contrast': 1, 'a11y-aria': 3, 'a11y-navigation': 4, 'a11y-language': 5, 'a11y-audio-video': 6, 'a11y-tables-lists': 7, // * SEO 'seo-mobile': 0, 'seo-content': 1, 'seo-crawl': 2, } // * Group audits by sub-group within a category (e.g., "Names and Labels", "Contrast") function AuditsBySubGroup({ audits }: { audits: AuditSummary[] }) { // * Collect unique sub-groups const bySubGroup: Record = {} for (const audit of audits) { const key = audit.sub_group || '__none__' if (!bySubGroup[key]) { bySubGroup[key] = [] } bySubGroup[key].push(audit) } const subGroupOrder = Object.keys(bySubGroup).sort((a, b) => { const pa = subGroupPriority[a] ?? 0 const pb = subGroupPriority[b] ?? 0 return pa - pb }) // * If no sub-groups exist, render flat list sorted by severity if (subGroupOrder.length === 1 && subGroupOrder[0] === '__none__') { return (
{sortBySeverity(audits).map(audit => )}
) } return (
{subGroupOrder.map(key => { const items = sortBySeverity(bySubGroup[key]) const title = items[0]?.sub_group_title return (
{title && (

{title}

)}
{items.map(audit => )}
) })}
) } // * Severity indicator based on audit score (pagespeed.web.dev style) function AuditSeverityIcon({ score }: { score: number | null }) { if (score === null) { return } if (score < 0.5) { return } if (score < 0.9) { return } 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 with parsed markdown links */} {audit.description && (

)} {/* Items list */} {audit.details && Array.isArray(audit.details) && audit.details.length > 0 && (
{audit.details.slice(0, 10).map((item: Record, idx: number) => ( ))} {audit.details.length > 10 && (

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

)}
)}
) } // * Parse markdown-style links [text](url) into clickable tags function AuditDescription({ text }: { text: string }) { const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g) return ( <> {parts.map((part, i) => { const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/) if (match) { const href = remapLearnUrl(match[2]) const isCiphera = href.startsWith('https://ciphera.net') return ( {match[1]} ) } return {part} })} ) } // * Render a single audit detail item — handles various field types from the PSI API function AuditItem({ item }: { item: Record }) { // * Determine the primary label const label = item.node?.nodeLabel || item.label || item.groupLabel || item.source?.url || null // * URL can be in item.url or item.href const url = item.url || item.href || null // * Text content (used by SEO audits like "link text") const text = item.text || item.linkText || null return (
{/* Element screenshot */} {item.node?.screenshot?.data && ( )} {/* Content */}
{label && (
{label}
)} {url && (
{url}
)} {text && (
{text}
)} {item.node?.snippet && ( {item.node.snippet} )} {/* Fallback for items with only string values we haven't handled */} {!label && !url && !text && !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`}
)}
) } // * Skeleton loading state function PageSpeedSkeleton() { return (
{/* Header — title + subtitle + toggle buttons */}
{/* Score overview — 4 gauge circles + screenshot */}
{[...Array(4)].map((_, i) => (
))}
{/* Legend bar */}
{/* Metrics card — 6 metrics in 3-col grid */}
{[...Array(6)].map((_, i) => (
))}
{/* Score trend chart placeholder */}
) }