From 52906344cf1f05dbfc12e1d95d751045078cf039 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 18:13:08 +0100 Subject: [PATCH] feat(pagespeed): add PageSpeed page with gauges, CWV cards, chart, and diagnostics - ScoreGauge SVG component with color-coded circular arcs - Full page: disabled state, score overview, CWV metrics, trend chart - Diagnostics accordion with opportunities/diagnostics/passed groups - Mobile/desktop strategy toggle, manual check trigger - Loading skeleton, frequency selector --- app/sites/[id]/pagespeed/page.tsx | 489 ++++++++++++++++++++++++++++ components/dashboard/Sidebar.tsx | 2 +- components/pagespeed/ScoreGauge.tsx | 71 ++++ 3 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 app/sites/[id]/pagespeed/page.tsx create mode 100644 components/pagespeed/ScoreGauge.tsx diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx new file mode 100644 index 0000000..cca1fdf --- /dev/null +++ b/app/sites/[id]/pagespeed/page.tsx @@ -0,0 +1,489 @@ +'use client' + +import { useAuth } from '@/lib/auth/context' +import { useEffect, useState } 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 + const handleRunCheck = async () => { + setRunning(true) + try { + await triggerPageSpeedCheck(siteId) + mutateLatest() + toast.success('PageSpeed check complete') + } catch (err: any) { + toast.error(err?.message || 'Failed to run check') + } finally { + 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 + const audits = currentCheck?.audits ?? [] + const opportunities = audits + .filter(a => a.category === 'opportunity') + .sort((a, b) => (b.savings_ms ?? 0) - (a.savings_ms ?? 0)) + const diagnostics = audits.filter(a => a.category === 'diagnostic') + const passed = audits.filter(a => a.category === 'passed') + + // * Core Web Vitals metrics + const metrics = [ + { key: 'lcp', label: 'Largest Contentful Paint', value: currentCheck?.lcp_ms ?? null }, + { key: 'cls', label: 'Cumulative Layout Shift', value: currentCheck?.cls ?? null }, + { key: 'tbt', label: 'Total Blocking Time', value: currentCheck?.tbt_ms ?? null }, + { key: 'fcp', label: 'First Contentful Paint', value: currentCheck?.fcp_ms ?? null }, + { key: 'si', label: 'Speed Index', value: currentCheck?.si_ms ?? null }, + { key: 'tti', label: 'Time to Interactive', value: currentCheck?.tti_ms ?? null }, + ] + + // * Enabled state — show full PageSpeed dashboard + return ( +
+ {/* Header */} +
+
+

+ PageSpeed +

+

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

+
+
+ {/* Mobile / Desktop toggle */} +
+ + +
+ + {canEdit && ( + <> + + + + )} +
+
+ + {/* Section 1 — Score Overview */} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + {/* Last checked info */} +
+ {currentCheck?.checked_at && ( + Last checked {formatTimeAgo(currentCheck.checked_at)} + )} + {config?.frequency && ( + + {config.frequency} + + )} +
+ + {/* Section 2 — Core Web Vitals */} +
+

+ Core Web Vitals +

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

+ Performance Score Trend +

+ + + + + + + + + + + + + + {value}} + /> + } + /> + + + +
+ )} + + {/* Section 4 — Diagnostics Accordion */} + {audits.length > 0 && ( +
+

+ Diagnostics +

+
+ {/* Opportunities */} + {opportunities.length > 0 && ( +
+ + Opportunities ({opportunities.length}) + +
+ {opportunities.map(audit => ( +
+
+ {audit.title} + {audit.display_value && ( + {audit.display_value} + )} +
+ ))} +
+
+ )} + + {/* Diagnostics */} + {diagnostics.length > 0 && ( +
+ + Diagnostics ({diagnostics.length}) + +
+ {diagnostics.map(audit => ( +
+
+ {audit.title} + {audit.display_value && ( + {audit.display_value} + )} +
+ ))} +
+
+ )} + + {/* Passed Audits */} + {passed.length > 0 && ( +
+ + Passed Audits ({passed.length}) + +
+ {passed.map(audit => ( +
+
+ {audit.title} + {audit.display_value && ( + {audit.display_value} + )} +
+ ))} +
+
+ )} +
+
+ )} +
+ ) +} + +// * Skeleton loading state +function PageSpeedSkeleton() { + return ( +
+
+
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+ ))} +
+
+ {[...Array(6)].map((_, i) => ( +
+
+
+
+ ))} +
+
+ ) +} diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 53ccc32..284208d 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -10,6 +10,7 @@ import { getUserOrganizations, switchContext, type OrganizationMember } from '@/ import { setSessionAction } from '@/app/actions/auth' import { logger } from '@/lib/utils/logger' import { FAVICON_SERVICE_URL } from '@/lib/utils/icons' +import { Gauge as GaugeIcon } from '@phosphor-icons/react' import { LayoutDashboardIcon, PathIcon, @@ -18,7 +19,6 @@ import { SearchIcon, CloudUploadIcon, HeartbeatIcon, - GaugeIcon, SettingsIcon, CollapseLeftIcon, CollapseRightIcon, diff --git a/components/pagespeed/ScoreGauge.tsx b/components/pagespeed/ScoreGauge.tsx new file mode 100644 index 0000000..78886ca --- /dev/null +++ b/components/pagespeed/ScoreGauge.tsx @@ -0,0 +1,71 @@ +'use client' + +interface ScoreGaugeProps { + score: number | null + label: string +} + +const RADIUS = 44 +const CIRCUMFERENCE = 2 * Math.PI * RADIUS + +function getColor(score: number): string { + if (score >= 90) return '#0cce6b' + if (score >= 50) return '#ffa400' + return '#ff4e42' +} + +export default function ScoreGauge({ score, label }: ScoreGaugeProps) { + const hasScore = score !== null && score !== undefined + const displayScore = hasScore ? Math.round(score) : null + const offset = hasScore ? CIRCUMFERENCE * (1 - score / 100) : CIRCUMFERENCE + const color = hasScore ? getColor(score) : '#6b7280' + + return ( +
+
+ + {/* Track */} + + {/* Filled arc */} + + + {/* Score text */} +
+ + {displayScore !== null ? displayScore : ( + -- + )} + +
+
+ + {label} + +
+ ) +}