From 780dd464a157a4d706ed92912d275ff591fc59fe Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 18:05:17 +0100 Subject: [PATCH 01/45] feat(pagespeed): add API client, SWR hooks, and sidebar navigation - PageSpeed API client with types for config, checks, and audits - SWR hooks: usePageSpeedConfig, usePageSpeedLatest, usePageSpeedHistory - GaugeIcon added to sidebar under Infrastructure group --- components/dashboard/Sidebar.tsx | 2 + lib/api/pagespeed.ts | 83 ++++++++++++++++++++++++++++++++ lib/swr/dashboard.ts | 31 ++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 lib/api/pagespeed.ts diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 042ac0a..53ccc32 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -18,6 +18,7 @@ import { SearchIcon, CloudUploadIcon, HeartbeatIcon, + GaugeIcon, SettingsIcon, CollapseLeftIcon, CollapseRightIcon, @@ -88,6 +89,7 @@ const NAV_GROUPS: NavGroup[] = [ items: [ { label: 'CDN', href: (id) => `/sites/${id}/cdn`, icon: CloudUploadIcon, matchPrefix: true }, { label: 'Uptime', href: (id) => `/sites/${id}/uptime`, icon: HeartbeatIcon, matchPrefix: true }, + { label: 'PageSpeed', href: (id) => `/sites/${id}/pagespeed`, icon: GaugeIcon, matchPrefix: true }, ], }, ] diff --git a/lib/api/pagespeed.ts b/lib/api/pagespeed.ts new file mode 100644 index 0000000..ede2fd7 --- /dev/null +++ b/lib/api/pagespeed.ts @@ -0,0 +1,83 @@ +import apiRequest from './client' + +// * Types for PageSpeed Insights monitoring + +export interface PageSpeedConfig { + site_id: string + enabled: boolean + frequency: 'daily' | 'weekly' | 'monthly' + url: string | null + next_check_at: string | null + created_at: string + updated_at: string +} + +export interface AuditSummary { + id: string + title: string + description: string + score: number | null + display_value?: string + savings_ms?: number + category: 'opportunity' | 'diagnostic' | 'passed' +} + +export interface PageSpeedCheck { + id: string + site_id: string + strategy: 'mobile' | 'desktop' + performance_score: number | null + accessibility_score: number | null + best_practices_score: number | null + seo_score: number | null + lcp_ms: number | null + cls: number | null + tbt_ms: number | null + fcp_ms: number | null + si_ms: number | null + tti_ms: number | null + audits: AuditSummary[] | null + triggered_by: 'scheduled' | 'manual' + checked_at: string +} + +export async function getPageSpeedConfig(siteId: string): Promise { + return apiRequest(`/sites/${siteId}/pagespeed/config`) +} + +export async function updatePageSpeedConfig( + siteId: string, + config: { enabled: boolean; frequency: string; url?: string } +): Promise { + return apiRequest(`/sites/${siteId}/pagespeed/config`, { + method: 'PUT', + body: JSON.stringify(config), + }) +} + +export async function getPageSpeedLatest(siteId: string): Promise { + const res = await apiRequest<{ checks: PageSpeedCheck[] }>(`/sites/${siteId}/pagespeed/latest`) + return res?.checks ?? [] +} + +export async function getPageSpeedHistory( + siteId: string, + strategy: 'mobile' | 'desktop' = 'mobile', + days = 90 +): Promise { + const res = await apiRequest<{ checks: PageSpeedCheck[] }>( + `/sites/${siteId}/pagespeed/history?strategy=${strategy}&days=${days}` + ) + return res?.checks ?? [] +} + +export async function getPageSpeedCheck(siteId: string, checkId: string): Promise { + return apiRequest(`/sites/${siteId}/pagespeed/checks/${checkId}`) +} + +export async function triggerPageSpeedCheck(siteId: string): Promise { + const res = await apiRequest<{ checks: PageSpeedCheck[] }>(`/sites/${siteId}/pagespeed/check`, { + method: 'POST', + }) + return res?.checks ?? [] +} diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts index 1962e67..b08feba 100644 --- a/lib/swr/dashboard.ts +++ b/lib/swr/dashboard.ts @@ -31,6 +31,7 @@ import { getSite } from '@/lib/api/sites' import type { Site } from '@/lib/api/sites' import { listFunnels, type Funnel } from '@/lib/api/funnels' import { getUptimeStatus, type UptimeStatusResponse } from '@/lib/api/uptime' +import { getPageSpeedConfig, getPageSpeedLatest, getPageSpeedHistory, type PageSpeedConfig, type PageSpeedCheck } from '@/lib/api/pagespeed' import { listGoals, type Goal } from '@/lib/api/goals' import { listReportSchedules, listAlertSchedules, type ReportSchedule } from '@/lib/api/report-schedules' import { listSessions, getBotFilterStats, type SessionSummary, type BotFilterStats } from '@/lib/api/bot-filter' @@ -79,6 +80,9 @@ const fetchers = { getJourneyEntryPoints(siteId, start, end), funnels: (siteId: string) => listFunnels(siteId), uptimeStatus: (siteId: string) => getUptimeStatus(siteId), + pageSpeedConfig: (siteId: string) => getPageSpeedConfig(siteId), + pageSpeedLatest: (siteId: string) => getPageSpeedLatest(siteId), + pageSpeedHistory: (siteId: string, strategy: 'mobile' | 'desktop', days: number) => getPageSpeedHistory(siteId, strategy, days), goals: (siteId: string) => listGoals(siteId), reportSchedules: (siteId: string) => listReportSchedules(siteId), alertSchedules: (siteId: string) => listAlertSchedules(siteId), @@ -550,5 +554,32 @@ export function useBotFilterStats(siteId: string) { ) } +// * Hook for PageSpeed config +export function usePageSpeedConfig(siteId: string) { + return useSWR( + siteId ? ['pageSpeedConfig', siteId] : null, + () => fetchers.pageSpeedConfig(siteId), + { ...dashboardSWRConfig, refreshInterval: 0, dedupingInterval: 10 * 1000 } + ) +} + +// * Hook for latest PageSpeed checks (mobile + desktop) +export function usePageSpeedLatest(siteId: string) { + return useSWR( + siteId ? ['pageSpeedLatest', siteId] : null, + () => fetchers.pageSpeedLatest(siteId), + { ...dashboardSWRConfig, refreshInterval: 60 * 1000, dedupingInterval: 10 * 1000, keepPreviousData: true } + ) +} + +// * Hook for PageSpeed score history (trend chart) +export function usePageSpeedHistory(siteId: string, strategy: 'mobile' | 'desktop', days = 90) { + return useSWR( + siteId ? ['pageSpeedHistory', siteId, strategy, days] : null, + () => fetchers.pageSpeedHistory(siteId, strategy, days), + { ...dashboardSWRConfig, refreshInterval: 60 * 1000, dedupingInterval: 10 * 1000, keepPreviousData: true } + ) +} + // * Re-export for convenience export { fetchers } From 52906344cf1f05dbfc12e1d95d751045078cf039 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 18:13:08 +0100 Subject: [PATCH 02/45] 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} + +
+ ) +} From d1af25266b85abb823c660086e9cae7adf3c718b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 18:28:06 +0100 Subject: [PATCH 03/45] fix(pagespeed): increase fetch timeout for manual PSI checks to 120s PSI checks run mobile + desktop sequentially (up to 60s total). The default 30s client timeout was causing false network errors. --- lib/api/pagespeed.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/api/pagespeed.ts b/lib/api/pagespeed.ts index ede2fd7..c67f8a7 100644 --- a/lib/api/pagespeed.ts +++ b/lib/api/pagespeed.ts @@ -76,8 +76,16 @@ export async function getPageSpeedCheck(siteId: string, checkId: string): Promis } export async function triggerPageSpeedCheck(siteId: string): Promise { - const res = await apiRequest<{ checks: PageSpeedCheck[] }>(`/sites/${siteId}/pagespeed/check`, { - method: 'POST', - }) - return res?.checks ?? [] + // * PSI checks take 10-30s per strategy (mobile + desktop sequential = up to 60s) + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 120_000) + try { + const res = await apiRequest<{ checks: PageSpeedCheck[] }>(`/sites/${siteId}/pagespeed/check`, { + method: 'POST', + signal: controller.signal, + }) + return res?.checks ?? [] + } finally { + clearTimeout(timeoutId) + } } From 2fd9bf82f121e6ebf53afcc988cdb2c90503277c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 18:35:17 +0100 Subject: [PATCH 04/45] fix(pagespeed): poll for results after async check trigger Backend now returns 202 immediately. Frontend polls every 5s for up to 2 minutes until new results appear, then shows success toast. --- app/sites/[id]/pagespeed/page.tsx | 42 +++++++++++++++++++++++++++---- lib/api/pagespeed.ts | 17 +++---------- 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index cca1fdf..6d7c894 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useAuth } from '@/lib/auth/context' -import { useEffect, useState } from 'react' +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' @@ -116,15 +116,47 @@ export default function PageSpeedPage() { } // * 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) - mutateLatest() - toast.success('PageSpeed check complete') + 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 run check') - } finally { + toast.error(err?.message || 'Failed to start check') setRunning(false) } } diff --git a/lib/api/pagespeed.ts b/lib/api/pagespeed.ts index c67f8a7..5650a9c 100644 --- a/lib/api/pagespeed.ts +++ b/lib/api/pagespeed.ts @@ -75,17 +75,8 @@ export async function getPageSpeedCheck(siteId: string, checkId: string): Promis return apiRequest(`/sites/${siteId}/pagespeed/checks/${checkId}`) } -export async function triggerPageSpeedCheck(siteId: string): Promise { - // * PSI checks take 10-30s per strategy (mobile + desktop sequential = up to 60s) - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 120_000) - try { - const res = await apiRequest<{ checks: PageSpeedCheck[] }>(`/sites/${siteId}/pagespeed/check`, { - method: 'POST', - signal: controller.signal, - }) - return res?.checks ?? [] - } finally { - clearTimeout(timeoutId) - } +// * Triggers an async PageSpeed check. Returns immediately (202). +// * Caller should poll getPageSpeedLatest() for results. +export async function triggerPageSpeedCheck(siteId: string): Promise { + await apiRequest(`/sites/${siteId}/pagespeed/check`, { method: 'POST' }) } From b0e6db36a198647f48259654bd251bde3b4d8646 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 18:54:45 +0100 Subject: [PATCH 05/45] feat(pagespeed): add screenshot display and expandable diagnostics - Page screenshot thumbnail next to score gauges - Expandable audit rows with description and detail items table - Shows URLs, HTML snippets, wasted bytes/ms for each failing element - AuditRow component replaces flat diagnostic rows --- app/sites/[id]/pagespeed/page.tsx | 141 +++++++++++++++++++++--------- lib/api/pagespeed.ts | 5 ++ 2 files changed, 104 insertions(+), 42 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 6d7c894..8e86056 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -306,19 +306,34 @@ export default function PageSpeedPage() {
{/* Section 1 — Score Overview */} -
-
- -
-
- -
-
- -
-
- +
+ {/* Score gauges */} +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {/* Screenshot */} + {currentCheck?.screenshot && ( +
+ {`${strategy} +
+ )}
{/* Last checked info */} @@ -432,16 +447,8 @@ export default function PageSpeedPage() { Opportunities ({opportunities.length}) -
- {opportunities.map(audit => ( -
-
- {audit.title} - {audit.display_value && ( - {audit.display_value} - )} -
- ))} +
+ {opportunities.map(audit => )}
)} @@ -452,16 +459,8 @@ export default function PageSpeedPage() { Diagnostics ({diagnostics.length}) -
- {diagnostics.map(audit => ( -
-
- {audit.title} - {audit.display_value && ( - {audit.display_value} - )} -
- ))} +
+ {diagnostics.map(audit => )}
)} @@ -472,16 +471,8 @@ export default function PageSpeedPage() { Passed Audits ({passed.length}) -
- {passed.map(audit => ( -
-
- {audit.title} - {audit.display_value && ( - {audit.display_value} - )} -
- ))} +
+ {passed.map(audit => )}
)} @@ -492,6 +483,72 @@ export default function PageSpeedPage() { ) } +// * Expandable audit row with description and detail items +function AuditRow({ audit }: { audit: AuditSummary }) { + return ( +
+ +
+ {audit.title} + {audit.display_value && ( + {audit.display_value} + )} + + + +
+
+ {/* Description */} + {audit.description && ( +

{audit.description}

+ )} + {/* Items table */} + {audit.details && Array.isArray(audit.details) && audit.details.length > 0 && ( +
+ + + {audit.details.slice(0, 10).map((item: Record, idx: number) => ( + + {/* URL or label */} + + {/* Wasted bytes */} + {item.wastedBytes != null && ( + + )} + {/* Total bytes */} + {item.totalBytes != null && !item.wastedBytes && ( + + )} + {/* Wasted ms */} + {item.wastedMs != null && ( + + )} + + ))} + +
+ {item.url ? ( + {item.url} + ) : item.node?.snippet ? ( + {item.node.snippet} + ) : item.label || item.groupLabel || item.statistic || ''} + + {item.wastedBytes < 1024 ? `${item.wastedBytes} B` : `${(item.wastedBytes / 1024).toFixed(1)} KiB`} + + {item.totalBytes < 1024 ? `${item.totalBytes} B` : `${(item.totalBytes / 1024).toFixed(1)} KiB`} + + {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 ( diff --git a/lib/api/pagespeed.ts b/lib/api/pagespeed.ts index 5650a9c..d3f4654 100644 --- a/lib/api/pagespeed.ts +++ b/lib/api/pagespeed.ts @@ -20,8 +20,12 @@ export interface AuditSummary { display_value?: string savings_ms?: number category: 'opportunity' | 'diagnostic' | 'passed' + details?: AuditDetailItem[] } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AuditDetailItem = Record + export interface PageSpeedCheck { id: string site_id: string @@ -37,6 +41,7 @@ export interface PageSpeedCheck { si_ms: number | null tti_ms: number | null audits: AuditSummary[] | null + screenshot?: string | null triggered_by: 'scheduled' | 'manual' checked_at: string } From 6b00b8b04a3dbf518a909047bce3ae5bfed48188 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 19:10:47 +0100 Subject: [PATCH 06/45] redesign(pagespeed): full page redesign inspired by pagespeed.web.dev - Hero card: large performance gauge + compact inline scores + screenshot - Single metrics card with 2x3 grid and colored status dots - Flat diagnostics list sorted by impact with severity indicators - ScoreGauge accepts size prop for flexible gauge sizing - Unicode severity markers (triangle/square/circle) per audit --- app/sites/[id]/pagespeed/page.tsx | 266 +++++++++++++++++----------- components/pagespeed/ScoreGauge.tsx | 9 +- 2 files changed, 164 insertions(+), 111 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 8e86056..df1f307 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -230,22 +230,51 @@ export default function PageSpeedPage() { // * 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 failingAudits = audits + .filter(a => a.category !== 'passed') + .sort((a, b) => { + // Opportunities first (sorted by savings_ms desc), then diagnostics + 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 + }) 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: '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 (
@@ -305,81 +334,101 @@ export default function PageSpeedPage() {
- {/* Section 1 — Score Overview */} -
- {/* Score gauges */} -
-
-
- + {/* 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} +
+ )}
- {/* Screenshot */} - {currentCheck?.screenshot && ( -
- {`${strategy} -
- )}
- {/* Last checked info */} -
- {currentCheck?.checked_at && ( - Last checked {formatTimeAgo(currentCheck.checked_at)} - )} - {config?.frequency && ( - - {config.frequency} - - )} -
- - {/* Section 2 — Core Web Vitals */} -
-

- Core Web Vitals + {/* Section 2 — Metrics Card */} +
+

+ Metrics

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

+
+

Performance Score Trend

@@ -434,73 +483,74 @@ export default function PageSpeedPage() {
)} - {/* Section 4 — Diagnostics Accordion */} + {/* Section 4 — Diagnostics */} {audits.length > 0 && ( -
+

Diagnostics

-
- {/* Opportunities */} - {opportunities.length > 0 && ( -
- - Opportunities ({opportunities.length}) - -
- {opportunities.map(audit => )} -
-
- )} - {/* Diagnostics */} - {diagnostics.length > 0 && ( -
- - Diagnostics ({diagnostics.length}) - -
- {diagnostics.map(audit => )} -
-
- )} + {/* Failing audits — flat list sorted by impact */} + {failingAudits.length > 0 && ( +
+ {failingAudits.map(audit => )} +
+ )} - {/* Passed Audits */} - {passed.length > 0 && ( -
- - Passed Audits ({passed.length}) - -
- {passed.map(audit => )} -
-
- )} -
+ {/* Passed audits — collapsed */} + {passed.length > 0 && ( +
+ + {passed.length} passed audit{passed.length !== 1 ? 's' : ''} + +
+ {passed.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.title} {audit.display_value && ( - {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}

+

{audit.description}

)} {/* Items table */} {audit.details && Array.isArray(audit.details) && audit.details.length > 0 && ( diff --git a/components/pagespeed/ScoreGauge.tsx b/components/pagespeed/ScoreGauge.tsx index 78886ca..7b9ceb4 100644 --- a/components/pagespeed/ScoreGauge.tsx +++ b/components/pagespeed/ScoreGauge.tsx @@ -3,6 +3,7 @@ interface ScoreGaugeProps { score: number | null label: string + size?: number } const RADIUS = 44 @@ -14,15 +15,17 @@ function getColor(score: number): string { return '#ff4e42' } -export default function ScoreGauge({ score, label }: ScoreGaugeProps) { +export default function ScoreGauge({ score, label, size = 120 }: 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' + const fontSize = size >= 160 ? 'text-4xl' : 'text-2xl' + return (
-
+
{displayScore !== null ? displayScore : ( From 50960d0556e0a42ac473f6e1a4489ebb9a6bb6b3 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 19:18:03 +0100 Subject: [PATCH 07/45] feat(pagespeed): render element screenshots in expandable audit items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows node screenshots, labels, HTML snippets, and URLs in audit detail rows — matching pagespeed.web.dev's failing elements display. --- app/sites/[id]/pagespeed/page.tsx | 89 ++++++++++++++++++------------- 1 file changed, 53 insertions(+), 36 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index df1f307..843a396 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -552,43 +552,60 @@ function AuditRow({ audit }: { audit: AuditSummary }) { {audit.description && (

{audit.description}

)} - {/* Items table */} + {/* Items list */} {audit.details && Array.isArray(audit.details) && audit.details.length > 0 && ( -
- - - {audit.details.slice(0, 10).map((item: Record, idx: number) => ( - - {/* URL or label */} - - {/* Wasted bytes */} - {item.wastedBytes != null && ( - - )} - {/* Total bytes */} - {item.totalBytes != null && !item.wastedBytes && ( - - )} - {/* Wasted ms */} - {item.wastedMs != null && ( - - )} - - ))} - -
- {item.url ? ( - {item.url} - ) : item.node?.snippet ? ( - {item.node.snippet} - ) : item.label || item.groupLabel || item.statistic || ''} - - {item.wastedBytes < 1024 ? `${item.wastedBytes} B` : `${(item.wastedBytes / 1024).toFixed(1)} KiB`} - - {item.totalBytes < 1024 ? `${item.totalBytes} B` : `${(item.totalBytes / 1024).toFixed(1)} KiB`} - - {item.wastedMs < 1000 ? `${Math.round(item.wastedMs)}ms` : `${(item.wastedMs / 1000).toFixed(1)}s`} -
+
+ {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

)} From fcbf21b71523bf91a55502d0448266e4403ab32f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 19:43:44 +0100 Subject: [PATCH 08/45] feat(pagespeed): render page load filmstrip between hero and metrics Horizontal scrollable filmstrip showing page rendering progression with timing labels. Appears between the score hero and metrics card. --- app/sites/[id]/pagespeed/page.tsx | 20 ++++++++++++++++++++ lib/api/pagespeed.ts | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 843a396..33d5f3e 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -403,6 +403,26 @@ export default function PageSpeedPage() {
+ {/* 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 */}

diff --git a/lib/api/pagespeed.ts b/lib/api/pagespeed.ts index d3f4654..f3d811e 100644 --- a/lib/api/pagespeed.ts +++ b/lib/api/pagespeed.ts @@ -26,6 +26,11 @@ export interface AuditSummary { // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AuditDetailItem = Record +export interface FilmstripFrame { + timing: number + data: string +} + export interface PageSpeedCheck { id: string site_id: string @@ -42,6 +47,7 @@ export interface PageSpeedCheck { tti_ms: number | null audits: AuditSummary[] | null screenshot?: string | null + filmstrip?: FilmstripFrame[] | null triggered_by: 'scheduled' | 'manual' checked_at: string } From 8649f37bb964a84bfbd62dc6fb45b550cea8c186 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 19:52:49 +0100 Subject: [PATCH 09/45] feat(pagespeed): split diagnostics by category (Performance, Accessibility, Best Practices, SEO) Each Lighthouse category gets its own card with failing audits sorted by impact and collapsed passed audits. Matches pagespeed.web.dev layout. --- app/sites/[id]/pagespeed/page.tsx | 84 +++++++++++++++++++------------ lib/api/pagespeed.ts | 1 + 2 files changed, 52 insertions(+), 33 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 33d5f3e..c0b0f34 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -228,21 +228,32 @@ export default function PageSpeedPage() { score: c.performance_score, })) - // * Parse audits into groups + // * Parse audits into groups by Lighthouse category const audits = currentCheck?.audits ?? [] - const failingAudits = audits - .filter(a => a.category !== 'passed') - .sort((a, b) => { - // Opportunities first (sorted by savings_ms desc), then diagnostics - 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 - }) 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 }, @@ -503,31 +514,38 @@ export default function PageSpeedPage() {

)} - {/* Section 4 — Diagnostics */} + {/* Section 4 — Diagnostics by Category */} {audits.length > 0 && ( -
-

- Diagnostics -

+
+ {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} +

- {/* Failing audits — flat list sorted by impact */} - {failingAudits.length > 0 && ( -
- {failingAudits.map(audit => )} -
- )} + {groupAudits.length > 0 && ( +
+ {groupAudits.map(audit => )} +
+ )} - {/* Passed audits — collapsed */} - {passed.length > 0 && ( -
- - {passed.length} passed audit{passed.length !== 1 ? 's' : ''} - -
- {passed.map(audit => )} + {groupPassed.length > 0 && ( +
+ + {groupPassed.length} passed audit{groupPassed.length !== 1 ? 's' : ''} + +
+ {groupPassed.map(audit => )} +
+
+ )}
-
- )} + ) + })}
)}
diff --git a/lib/api/pagespeed.ts b/lib/api/pagespeed.ts index f3d811e..b036d52 100644 --- a/lib/api/pagespeed.ts +++ b/lib/api/pagespeed.ts @@ -20,6 +20,7 @@ export interface AuditSummary { display_value?: string savings_ms?: number category: 'opportunity' | 'diagnostic' | 'passed' + group?: string // "performance", "accessibility", "best-practices", "seo" details?: AuditDetailItem[] } From dd0700cbea403ed99444e1c5accdeb5df75fcb07 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 19:56:00 +0100 Subject: [PATCH 10/45] fix(pagespeed): poll silently without triggering SWR re-renders Use direct API fetch for polling instead of mutateLatest() which was causing the page to flicker and clear data every 5 seconds. SWR cache is only updated once when new results arrive. --- app/sites/[id]/pagespeed/page.tsx | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index c0b0f34..081ae72 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -4,7 +4,7 @@ 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 { updatePageSpeedConfig, triggerPageSpeedCheck, getPageSpeedLatest, type PageSpeedCheck, type AuditSummary } from '@/lib/api/pagespeed' import { toast, Button } from '@ciphera-net/ui' import ScoreGauge from '@/components/pagespeed/ScoreGauge' import { @@ -116,7 +116,6 @@ export default function PageSpeedPage() { } // * Trigger a manual PageSpeed check - // * Poll for results after triggering an async check const pollRef = useRef | null>(null) const stopPolling = useCallback(() => { if (pollRef.current) { @@ -125,7 +124,6 @@ export default function PageSpeedPage() { } }, []) - // * Clean up polling on unmount useEffect(() => () => stopPolling(), [stopPolling]) const handleRunCheck = async () => { @@ -134,25 +132,29 @@ export default function PageSpeedPage() { 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() + // * 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 () => { - const elapsed = Date.now() - startedAt - if (elapsed > 120_000) { + if (Date.now() - startedAt > 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') + 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) { From 783530940eb38cba142becf7cd0de4ccf25b2463 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 20:19:07 +0100 Subject: [PATCH 11/45] polish(pagespeed): design consistency pass - Filmstrip: dark mode bg fix, consistent card padding, scroll fade - Metrics: font-semibold to match uptime page - Hero: tighter compact scores, smaller legend, centered alignment - Chart: hide x-axis when single day, height matches uptime (h-40) - Diagnostics: hide categories with zero failures, muted display values - Skeleton: matches new hero layout --- app/sites/[id]/pagespeed/page.tsx | 89 ++++++++++++++++++------------- 1 file changed, 52 insertions(+), 37 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 081ae72..ed2c8b2 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -229,6 +229,9 @@ export default function PageSpeedPage() { 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 ?? [] @@ -349,7 +352,7 @@ export default function PageSpeedPage() { {/* Section 1 — Hero Card: Score Gauge + Compact Scores + Screenshot */}
-
+
{/* Left — Large Performance Gauge */}
@@ -357,17 +360,17 @@ export default function PageSpeedPage() { {/* Center — Compact Scores + Meta */}
-
+
{compactScores.map(({ label, score }) => ( -
+
- + {score !== null ? Math.round(score) : '--'} - + {label}
@@ -387,18 +390,18 @@ export default function PageSpeedPage() {
{/* Score Legend */} -
- - - 0–49 Poor +
+ + + 0–49 - - - 50–89 Needs Improvement + + + 50–89 - - - 90–100 Good + + + 90–100
@@ -418,14 +421,14 @@ export default function PageSpeedPage() { {/* 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`} @@ -433,6 +436,8 @@ export default function PageSpeedPage() {
))}
+ {/* Fade indicator for horizontal scroll */} +
)} @@ -449,7 +454,7 @@ export default function PageSpeedPage() {
{label}
-
+
{formatMetricValue(key, value)}
@@ -464,7 +469,7 @@ export default function PageSpeedPage() {

Performance Score Trend

- + @@ -480,10 +485,11 @@ export default function PageSpeedPage() { /> { const groupAudits = auditsByGroup[group.key] ?? [] const groupPassed = passed.filter(a => a.group === group.key) - if (groupAudits.length === 0 && groupPassed.length === 0) return null + // * Hide categories with no failing audits — showing only passed count adds no value + if (groupAudits.length === 0) return null return (

@@ -576,7 +583,7 @@ function AuditRow({ audit }: { audit: AuditSummary }) { {audit.title} {audit.display_value && ( - {audit.display_value} + {audit.display_value} )} {audit.savings_ms != null && audit.savings_ms > 0 && !audit.display_value && ( @@ -664,21 +671,29 @@ function PageSpeedSkeleton() {
-
- {[...Array(4)].map((_, i) => ( -
-
-
+ {/* Hero skeleton */} +
+
+
+
+
+
+
- ))} +
+
-
- {[...Array(6)].map((_, i) => ( -
-
-
-
- ))} + {/* Metrics skeleton */} +
+
+
+ {[...Array(6)].map((_, i) => ( +
+
+
+
+ ))} +
) From 8b95620ec155ebf8dcb39ac8ec28c667f5e51362 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 20:43:11 +0100 Subject: [PATCH 12/45] polish(pagespeed): mini gauges, animated tab switcher, filmstrip title - 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) --- app/sites/[id]/pagespeed/page.tsx | 63 +++++++++++++---------------- components/pagespeed/ScoreGauge.tsx | 13 +++--- 2 files changed, 37 insertions(+), 39 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index ed2c8b2..af19026 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -6,6 +6,7 @@ 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, @@ -306,27 +307,29 @@ export default function PageSpeedPage() {
{/* Mobile / Desktop toggle */} -
- - +
+ {(['mobile', 'desktop'] as const).map(tab => ( + + ))}
{canEdit && ( @@ -360,20 +363,9 @@ export default function PageSpeedPage() { {/* Center — Compact Scores + Meta */}
-
+
{compactScores.map(({ label, score }) => ( -
- - - {score !== null ? Math.round(score) : '--'} - - - {label} - -
+ ))}
@@ -422,6 +414,9 @@ export default function PageSpeedPage() { {/* Filmstrip — page load progression */} {currentCheck?.filmstrip && currentCheck.filmstrip.length > 0 && (
+

+ Page Load Timeline +

{currentCheck.filmstrip.map((frame, idx) => (
diff --git a/components/pagespeed/ScoreGauge.tsx b/components/pagespeed/ScoreGauge.tsx index 7b9ceb4..617742c 100644 --- a/components/pagespeed/ScoreGauge.tsx +++ b/components/pagespeed/ScoreGauge.tsx @@ -21,10 +21,13 @@ export default function ScoreGauge({ score, label, size = 120 }: ScoreGaugeProps const offset = hasScore ? CIRCUMFERENCE * (1 - score / 100) : CIRCUMFERENCE const color = hasScore ? getColor(score) : '#6b7280' - const fontSize = size >= 160 ? 'text-4xl' : 'text-2xl' + const fontSize = size >= 160 ? 'text-4xl' : size >= 100 ? 'text-2xl' : size >= 80 ? 'text-lg' : 'text-xs' + const labelSize = size >= 100 ? 'text-sm' : 'text-[10px]' + const strokeWidth = size >= 100 ? 8 : 6 + const gap = size >= 100 ? 'gap-2' : 'gap-1' return ( -
+
{/* Filled arc */}
- + {label}
From ab6008daf9ca3ffee327bdb6cd4b2a19c4d3cb2b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 20:52:50 +0100 Subject: [PATCH 13/45] fix(pagespeed): parse markdown links + handle more audit item fields - AuditDescription: converts [text](url) to clickable links - AuditItem: handles href, text/linkText, source.url from PSI API --- app/sites/[id]/pagespeed/page.tsx | 144 +++++++++++++++++++----------- 1 file changed, 93 insertions(+), 51 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index af19026..8d047ae 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -590,63 +590,17 @@ function AuditRow({ audit }: { audit: AuditSummary }) {

- {/* Description */} + {/* Description with parsed markdown links */} {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

@@ -658,6 +612,94 @@ function AuditRow({ audit }: { audit: AuditSummary }) { ) } +// * 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) { + 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 ( From 5003175305fe0a1615d7b1009bb2f22afc78cdc1 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 20:55:55 +0100 Subject: [PATCH 14/45] redesign(pagespeed): equal gauges in hero + category gauges in diagnostics - Hero: 4 equal 90px ScoreGauges in a row with screenshot on right - Diagnostics: each category card gets a 56px gauge header with score and issue count, matching pagespeed.web.dev's category sections - Legend and metadata moved to footer bar in hero card --- app/sites/[id]/pagespeed/page.tsx | 109 ++++++++++++++---------------- 1 file changed, 52 insertions(+), 57 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 8d047ae..bb41293 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -270,18 +270,20 @@ export default function PageSpeedPage() { { 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 }, + // * 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 }, ] - function getScoreColor(score: number | null): string { - if (score === null) return '#6b7280' - if (score >= 90) return '#0cce6b' - if (score >= 50) return '#ffa400' - return '#ff4e42' + // * 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 { @@ -353,62 +355,46 @@ export default function PageSpeedPage() {
- {/* Section 1 — Hero Card: Score Gauge + Compact Scores + Screenshot */} + {/* Section 1 — Score Overview: 4 equal gauges + screenshot */}
-
- {/* Left — Large Performance Gauge */} -
- +
+ {/* 4 equal gauges */} +
+ {allScores.map(({ label, score }) => ( + + ))}
- {/* Center — Compact Scores + Meta */} -
-
- {compactScores.map(({ label, score }) => ( - - ))} -
- - {/* Last checked + frequency */} -
- {currentCheck?.checked_at && ( - Last checked {formatTimeAgo(currentCheck.checked_at)} - )} - {config?.frequency && ( - - {config.frequency} - - )} -
- - {/* Score Legend */} -
- - - 0–49 - - - - 50–89 - - - - 90–100 - -
-
- - {/* Right — Screenshot */} + {/* Screenshot */} {currentCheck?.screenshot && (
{`${strategy}
)}
+ + {/* Last checked + frequency + legend */} +
+
+ {currentCheck?.checked_at && ( + Last checked {formatTimeAgo(currentCheck.checked_at)} + )} + {config?.frequency && ( + + {config.frequency} + + )} +
+
+ 0–49 + 50–89 + 90–100 +
+
{/* Filmstrip — page load progression */} @@ -527,9 +513,18 @@ export default function PageSpeedPage() { if (groupAudits.length === 0) return null return (
-

- {group.label} -

+ {/* Category header with gauge */} +
+ +
+

+ {group.label} +

+

+ {groupAudits.length} issue{groupAudits.length !== 1 ? 's' : ''} found +

+
+
{groupAudits.length > 0 && (
From dfcf6bebdec423fd4f6c5388d69c53366a8ede56 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 20:59:52 +0100 Subject: [PATCH 15/45] fix(pagespeed): show all 4 category cards including those with 0 issues --- app/sites/[id]/pagespeed/page.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index bb41293..7d07859 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -509,8 +509,7 @@ export default function PageSpeedPage() { {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 + if (groupAudits.length === 0 && groupPassed.length === 0) return null return (
{/* Category header with gauge */} @@ -521,7 +520,7 @@ export default function PageSpeedPage() { {group.label}

- {groupAudits.length} issue{groupAudits.length !== 1 ? 's' : ''} found + {groupAudits.length === 0 ? 'No issues found' : `${groupAudits.length} issue${groupAudits.length !== 1 ? 's' : ''} found`}

From a0173636d47f45236120b021e78b5fca9029d8c1 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 21:08:50 +0100 Subject: [PATCH 16/45] fix(pagespeed): show empty circle for unscored/informative audits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Null scores now show ○ (informative) instead of ▲ (poor), matching pagespeed.web.dev's "Unscored" indicator for informative audits. --- app/sites/[id]/pagespeed/page.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 7d07859..c66ef4f 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -552,8 +552,12 @@ export default function PageSpeedPage() { // * 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 + if (score === null) { + // Empty circle for informative/unscored audits + return + } + if (score < 0.5) { + // Red triangle for poor return } if (score < 0.9) { From 98429f82f5a91d81184a90848d391a66c58fea29 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 22:03:13 +0100 Subject: [PATCH 17/45] feat(pagespeed): render audit sub-group headers in diagnostics Group audits within each category by sub-group (e.g., "Names and Labels", "Contrast") with small uppercase headers, matching the pagespeed.web.dev layout. --- app/sites/[id]/pagespeed/page.tsx | 50 +++++++++++++++++++++++++++++-- lib/api/pagespeed.ts | 2 ++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index c66ef4f..2a5c39b 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -526,9 +526,7 @@ export default function PageSpeedPage() {
{groupAudits.length > 0 && ( -
- {groupAudits.map(audit => )} -
+ )} {groupPassed.length > 0 && ( @@ -550,6 +548,52 @@ export default function PageSpeedPage() { ) } +// * Group audits by sub-group within a category (e.g., "Names and Labels", "Contrast") +function AuditsBySubGroup({ audits }: { audits: AuditSummary[] }) { + // * Collect unique sub-groups in order of appearance + const subGroupOrder: string[] = [] + const bySubGroup: Record = {} + + for (const audit of audits) { + const key = audit.sub_group || '__none__' + if (!bySubGroup[key]) { + bySubGroup[key] = [] + subGroupOrder.push(key) + } + bySubGroup[key].push(audit) + } + + // * If no sub-groups exist, render flat list + if (subGroupOrder.length === 1 && subGroupOrder[0] === '__none__') { + return ( +
+ {audits.map(audit => )} +
+ ) + } + + return ( +
+ {subGroupOrder.map(key => { + const items = 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) { diff --git a/lib/api/pagespeed.ts b/lib/api/pagespeed.ts index b036d52..840b3df 100644 --- a/lib/api/pagespeed.ts +++ b/lib/api/pagespeed.ts @@ -21,6 +21,8 @@ export interface AuditSummary { savings_ms?: number category: 'opportunity' | 'diagnostic' | 'passed' group?: string // "performance", "accessibility", "best-practices", "seo" + sub_group?: string // "a11y-names-labels", "a11y-contrast", etc. + sub_group_title?: string // "Names and Labels", "Contrast", etc. details?: AuditDetailItem[] } From 9d1d2dbb809a2e983766d7b7c690b499ca72bd4d Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 22:11:49 +0100 Subject: [PATCH 18/45] fix(pagespeed): issue count excludes informative/unscored audits Only audits with a real score (non-null) count toward the issue total. Informative audits (score: null) are shown but not counted. --- app/sites/[id]/pagespeed/page.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 2a5c39b..96e96be 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -520,7 +520,10 @@ export default function PageSpeedPage() { {group.label}

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

From d232a8a6d102a3ce02314d7e9f6a1d6f562f6596 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 23:25:11 +0100 Subject: [PATCH 19/45] feat(pagespeed): sort audits by severity + insights before diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sort order within each sub-group: red → orange → empty → green. Sub-groups sorted so insights come before diagnostics. --- app/sites/[id]/pagespeed/page.tsx | 34 +++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 96e96be..2f48d21 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -551,26 +551,48 @@ export default function PageSpeedPage() { ) } +// * 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 = { + 'budgets': 0, 'load-opportunities': 0, 'diagnostics': 1, +} + // * Group audits by sub-group within a category (e.g., "Names and Labels", "Contrast") function AuditsBySubGroup({ audits }: { audits: AuditSummary[] }) { - // * Collect unique sub-groups in order of appearance - const subGroupOrder: string[] = [] + // * Collect unique sub-groups const bySubGroup: Record = {} for (const audit of audits) { const key = audit.sub_group || '__none__' if (!bySubGroup[key]) { bySubGroup[key] = [] - subGroupOrder.push(key) } bySubGroup[key].push(audit) } - // * If no sub-groups exist, render flat list + 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 (
- {audits.map(audit => )} + {sortBySeverity(audits).map(audit => )}
) } @@ -578,7 +600,7 @@ function AuditsBySubGroup({ audits }: { audits: AuditSummary[] }) { return (
{subGroupOrder.map(key => { - const items = bySubGroup[key] + const items = sortBySeverity(bySubGroup[key]) const title = items[0]?.sub_group_title return (
From 354331646baa393157114fd1876ebd2e0e96a78e Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 23:38:58 +0100 Subject: [PATCH 20/45] =?UTF-8?q?fix(pagespeed):=20order=20accessibility?= =?UTF-8?q?=20sub-groups:=20names/labels=20=E2=86=92=20contrast=20?= =?UTF-8?q?=E2=86=92=20best=20practices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/sites/[id]/pagespeed/page.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 2f48d21..7a7af2a 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -566,7 +566,14 @@ function sortBySeverity(audits: AuditSummary[]): AuditSummary[] { // * 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") From bba25c722aea9b673f01c52931504542ddec39fc Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 23:45:36 +0100 Subject: [PATCH 21/45] feat(pagespeed): manual check section, consistent dot indicators - Add "Additional items to manually check" collapsed section - Replace triangle/square severity icons with consistent filled circles - Empty circle (border only) for informative/unscored audits --- app/sites/[id]/pagespeed/page.tsx | 30 ++++++++++++++++++++---------- lib/api/pagespeed.ts | 2 +- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 7a7af2a..5463f20 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -247,9 +247,10 @@ export default function PageSpeedPage() { // * 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.group === group.key) + .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 @@ -258,6 +259,7 @@ export default function PageSpeedPage() { } return 0 }) + manualByGroup[group.key] = audits.filter(a => a.category === 'manual' && a.group === group.key) } // * Core Web Vitals metrics @@ -509,7 +511,8 @@ export default function PageSpeedPage() { {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 + const groupManual = manualByGroup[group.key] ?? [] + if (groupAudits.length === 0 && groupPassed.length === 0 && groupManual.length === 0) return null return (
{/* Category header with gauge */} @@ -532,6 +535,17 @@ export default function PageSpeedPage() { )} + {groupManual.length > 0 && ( +
+ + Additional items to manually check ({groupManual.length}) + +
+ {groupManual.map(audit => )} +
+
+ )} + {groupPassed.length > 0 && (
@@ -629,19 +643,15 @@ function AuditsBySubGroup({ audits }: { audits: AuditSummary[] }) { // * Severity indicator based on audit score (pagespeed.web.dev style) function AuditSeverityIcon({ score }: { score: number | null }) { if (score === null) { - // Empty circle for informative/unscored audits - return + return } if (score < 0.5) { - // Red triangle for poor - return + return } if (score < 0.9) { - // Amber square for needs improvement - return + return } - // Green circle for good - return + return } // * Expandable audit row with description and detail items diff --git a/lib/api/pagespeed.ts b/lib/api/pagespeed.ts index 840b3df..97a45d8 100644 --- a/lib/api/pagespeed.ts +++ b/lib/api/pagespeed.ts @@ -19,7 +19,7 @@ export interface AuditSummary { score: number | null display_value?: string savings_ms?: number - category: 'opportunity' | 'diagnostic' | 'passed' + category: 'opportunity' | 'diagnostic' | 'passed' | 'manual' group?: string // "performance", "accessibility", "best-practices", "seo" sub_group?: string // "a11y-names-labels", "a11y-contrast", etc. sub_group_title?: string // "Names and Labels", "Contrast", etc. From 98fcce46478d89e3cf324278880af8715e5726bd Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 23 Mar 2026 10:54:09 +0100 Subject: [PATCH 22/45] feat(pagespeed): switch trend chart from Recharts to visx for dashboard consistency --- app/sites/[id]/pagespeed/page.tsx | 103 ++++++++++-------------------- 1 file changed, 35 insertions(+), 68 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 5463f20..3a22b43 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -8,20 +8,7 @@ import { updatePageSpeedConfig, triggerPageSpeedCheck, getPageSpeedLatest, type 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 +import { AreaChart as VisxAreaChart, Area as VisxArea, Grid as VisxGrid, XAxis as VisxXAxis, YAxis as VisxYAxis, ChartTooltip as VisxChartTooltip } from '@/components/ui/area-chart' // * Metric status thresholds (Google's Core Web Vitals thresholds) function getMetricStatus(metric: string, value: number | null): { label: string; color: string } { @@ -225,14 +212,11 @@ export default function PageSpeedPage() { ) } - // * Prepare chart data from history + // * Prepare chart data from history (visx needs Date objects for x-axis) const chartData = (historyChecks ?? []).map(c => ({ - date: new Date(c.checked_at).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }), - score: c.performance_score, + dateObj: new Date(c.checked_at), + score: c.performance_score ?? 0, })) - // * 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 ?? [] @@ -446,62 +430,45 @@ export default function PageSpeedPage() {
- {/* Section 3 — Score Trend Chart */} + {/* Section 3 — Score Trend Chart (visx) */} {chartData.length >= 2 && (

Performance Score Trend

- - - - - - - - - - - - - - {value}} - /> - } - /> - + []} + xDataKey="dateObj" + aspectRatio="3 / 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)', + }]} + /> + +
)} From d02d8429e292002c9250f7df9c417a0bf3323af4 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 23 Mar 2026 11:26:18 +0100 Subject: [PATCH 23/45] fix(pagespeed): contain visx chart within card bounds --- app/sites/[id]/pagespeed/page.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 3a22b43..ab0d09f 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -432,15 +432,15 @@ export default function PageSpeedPage() { {/* Section 3 — Score Trend Chart (visx) */} {chartData.length >= 2 && ( -
+

Performance Score Trend

-
+
[]} xDataKey="dateObj" - aspectRatio="3 / 1" + aspectRatio="4 / 1" margin={{ top: 10, right: 10, bottom: 30, left: 40 }} > From 8d9a3f35927655ffb89364c95780fd257fd539a5 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 23 Mar 2026 11:34:05 +0100 Subject: [PATCH 24/45] feat(pagespeed): add check history navigation with prev/next arrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigate between historical checks using ◀ ▶ arrows in the hero footer bar. Shows formatted date when viewing historical data, "Last checked X ago" when on latest. Fetches full audit data via getPageSpeedCheck when navigating to a historical check. --- app/sites/[id]/pagespeed/page.tsx | 121 ++++++++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 8 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index ab0d09f..6ff082c 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -1,10 +1,10 @@ 'use client' import { useAuth } from '@/lib/auth/context' -import { useEffect, useState, useRef, useCallback } from 'react' +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, type PageSpeedCheck, type AuditSummary } from '@/lib/api/pagespeed' +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' @@ -75,8 +75,80 @@ export default function PageSpeedPage() { const { data: historyChecks } = usePageSpeedHistory(siteId, strategy) - // * Get the check for the current strategy - const currentCheck = latestChecks?.find(c => c.strategy === strategy) ?? null + // * 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(() => { @@ -299,7 +371,7 @@ export default function PageSpeedPage() { {(['mobile', 'desktop'] as const).map(tab => (
- {/* Last checked + frequency + legend */} + {/* Check navigator + frequency + legend */}
-
+
+ {/* Prev/Next arrows */} + {checkTimestamps.length > 1 && ( + + )} {currentCheck?.checked_at && ( - Last checked {formatTimeAgo(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 From a0ef570137a9a55ef830dbbb79ee0fae69587e84 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 23 Mar 2026 11:51:40 +0100 Subject: [PATCH 25/45] feat(pagespeed): inline frequency selector in hero footer Replace static frequency badge with a pill toggle (Daily/Weekly/Monthly) matching the Mobile/Desktop tab style. Updates config via API on click. Read-only badge shown for non-admin users. --- app/sites/[id]/pagespeed/page.tsx | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 6ff082c..71d3265 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -471,7 +471,34 @@ export default function PageSpeedPage() { )} - {config?.frequency && ( + {/* Frequency selector */} + {canEdit && config && ( +
+ {(['daily', 'weekly', 'monthly'] as const).map(f => ( + + ))} +
+ )} + {!canEdit && config?.frequency && ( {config.frequency} From 31471792f896bcac6b912d664dfa3a409da1729c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 23 Mar 2026 11:58:09 +0100 Subject: [PATCH 26/45] feat(pagespeed): move frequency selector to site settings Revert inline frequency toggle from pagespeed page. Add PageSpeed Monitoring section to site settings under the Data tab with a Select dropdown for Daily/Weekly/Monthly. Shows "Not enabled" when PSI is off. --- app/sites/[id]/pagespeed/page.tsx | 29 +------------------- app/sites/[id]/settings/page.tsx | 45 ++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 71d3265..6ff082c 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -471,34 +471,7 @@ export default function PageSpeedPage() { )} - {/* Frequency selector */} - {canEdit && config && ( -
- {(['daily', 'weekly', 'monthly'] as const).map(f => ( - - ))} -
- )} - {!canEdit && config?.frequency && ( + {config?.frequency && ( {config.frequency} diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index 6e1520f..3b2d05d 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -21,7 +21,8 @@ import { Select, Modal, Button } from '@ciphera-net/ui' import { APP_URL } from '@/lib/api/client' import { generatePrivacySnippet } from '@/lib/utils/privacySnippet' import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges' -import { useSite, useGoals, useReportSchedules, useAlertSchedules, useSubscription, useGSCStatus, useBunnyStatus, useSessions, useBotFilterStats } from '@/lib/swr/dashboard' +import { useSite, useGoals, useReportSchedules, useAlertSchedules, useSubscription, useGSCStatus, useBunnyStatus, useSessions, useBotFilterStats, usePageSpeedConfig } from '@/lib/swr/dashboard' +import { updatePageSpeedConfig } from '@/lib/api/pagespeed' import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans' import { motion, AnimatePresence } from 'framer-motion' import { useAuth } from '@/lib/auth/context' @@ -130,6 +131,7 @@ export default function SiteSettingsPage() { const [gscConnecting, setGscConnecting] = useState(false) const [gscDisconnecting, setGscDisconnecting] = useState(false) const { data: bunnyStatus, mutate: mutateBunnyStatus } = useBunnyStatus(siteId) + const { data: psiConfig, mutate: mutatePSIConfig } = usePageSpeedConfig(siteId) const [bunnyApiKey, setBunnyApiKey] = useState('') const [bunnyPullZones, setBunnyPullZones] = useState([]) const [bunnySelectedZone, setBunnySelectedZone] = useState(null) @@ -1345,6 +1347,47 @@ export default function SiteSettingsPage() {
+ {/* PageSpeed Monitoring */} +
+

PageSpeed Monitoring

+
+
+
+

Check frequency

+

+ How often PageSpeed Insights runs automated checks on your site. +

+
+ {psiConfig?.enabled ? ( + handleFrequencyChange(e.target.value)} + className="text-xs bg-transparent border-none text-neutral-500 dark:text-neutral-400 cursor-pointer focus:outline-none focus:ring-0 p-0 pr-4 appearance-none" + style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")`, backgroundRepeat: 'no-repeat', backgroundPosition: 'right center' }} + > + + + + + ) : ( + + {config?.frequency} + + )} + {/* Next check indicator */} + {config?.next_check_at && !selectedCheckId && ( + <> + · + + Next {formatTimeUntil(config.next_check_at)} + + + )}
0–49 @@ -508,8 +553,8 @@ export default function PageSpeedPage() {
))}
- {/* Fade indicator for horizontal scroll */} -
+ {/* Fade indicator for horizontal scroll — only covers padding area */} +
)} From 8c3b77e8e5d73f30f4beefcd05e1c4261c66ed1c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 23 Mar 2026 14:46:10 +0100 Subject: [PATCH 32/45] Revert "fix(pagespeed): make frequency interactive and show next check time" This reverts commit 01c50ab971be69d6d8eb9daa99c93e5e8d53a1b7. --- app/sites/[id]/pagespeed/page.tsx | 71 ++++++------------------------- 1 file changed, 13 insertions(+), 58 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 8ef7e7d..6ff082c 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -49,18 +49,6 @@ function formatTimeAgo(dateString: string | null): string { return `${Math.floor(diffSec / 86400)}d ago` } -function formatTimeUntil(dateString: string | null): string | null { - if (!dateString) return null - const date = new Date(dateString) - const now = new Date() - const diffMs = date.getTime() - now.getTime() - if (diffMs <= 0) return 'soon' - const diffSec = Math.floor(diffMs / 1000) - if (diffSec < 3600) return `in ${Math.floor(diffSec / 60)}m` - if (diffSec < 86400) return `in ${Math.floor(diffSec / 3600)}h` - return `in ${Math.floor(diffSec / 86400)}d` -} - // * Get dot color for audit items based on score function getAuditDotColor(score: number | null): string { if (score === null) return 'bg-neutral-400' @@ -187,18 +175,6 @@ export default function PageSpeedPage() { } } - // * Change frequency inline (without disabling/re-enabling) - const handleFrequencyChange = async (newFrequency: string) => { - setFrequency(newFrequency) - try { - await updatePageSpeedConfig(siteId, { enabled: true, frequency: newFrequency }) - mutateConfig() - } catch { - toast.error('Failed to update check frequency') - if (config?.frequency) setFrequency(config.frequency) - } - } - // * Trigger a manual PageSpeed check const pollRef = useRef | null>(null) const stopPolling = useCallback(() => { @@ -424,13 +400,14 @@ export default function PageSpeedPage() { > {running ? 'Running...' : 'Run Check'} - + )}
@@ -466,7 +443,7 @@ export default function PageSpeedPage() {
@@ -553,8 +508,8 @@ export default function PageSpeedPage() {
))}
- {/* Fade indicator for horizontal scroll — only covers padding area */} -
+ {/* Fade indicator for horizontal scroll */} +
)} From cbb7445d74b57177c152aa6bf26ab3a4d2329fe9 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 23 Mar 2026 14:55:05 +0100 Subject: [PATCH 33/45] feat(pagespeed): click score gauges to scroll to diagnostics category --- app/sites/[id]/pagespeed/page.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx index 6ff082c..0e9354b 100644 --- a/app/sites/[id]/pagespeed/page.tsx +++ b/app/sites/[id]/pagespeed/page.tsx @@ -416,10 +416,16 @@ export default function PageSpeedPage() { {/* Section 1 — Score Overview: 4 equal gauges + screenshot */}
- {/* 4 equal gauges */} + {/* 4 equal gauges — click to scroll to diagnostics */}
- {allScores.map(({ label, score }) => ( - + {allScores.map(({ key, label, score }) => ( + ))}
@@ -586,7 +592,7 @@ export default function PageSpeedPage() { const groupManual = manualByGroup[group.key] ?? [] if (groupAudits.length === 0 && groupPassed.length === 0 && groupManual.length === 0) return null return ( -
+
{/* Category header with gauge */}
From 198bd3b00ffd6460372d845b2fed4735e8c8d5a7 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 23 Mar 2026 15:15:28 +0100 Subject: [PATCH 34/45] feat(sidebar): extract SidebarContent to proper React component Convert the sidebarContent(isMobile) closure function to a proper SidebarContent component with explicit props, enabling correct React reconciliation for both desktop and mobile sidebar instances. --- components/dashboard/Sidebar.tsx | 257 +++++++++++++++++++------------ 1 file changed, 159 insertions(+), 98 deletions(-) diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index b5d514b..41a35e5 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -269,6 +269,129 @@ function NavLink({ ) } +// ─── Sidebar Content ──────────────────────────────────────── + +interface SidebarContentProps { + isMobile: boolean + collapsed: boolean + siteId: string + sites: Site[] + canEdit: boolean + pendingHref: string | null + onNavigate: (href: string) => void + onMobileClose: () => void + onExpand: () => void + onCollapse: () => void + onToggle: () => void + wasCollapsed: React.MutableRefObject + auth: ReturnType + orgs: OrganizationMember[] + onSwitchOrganization: (orgId: string | null) => Promise + openSettings: () => void +} + +function SidebarContent({ + isMobile, collapsed, siteId, sites, canEdit, pendingHref, + onNavigate, onMobileClose, onExpand, onCollapse, onToggle, + wasCollapsed, auth, orgs, onSwitchOrganization, openSettings, +}: SidebarContentProps) { + const router = useRouter() + const c = isMobile ? false : collapsed + const { user } = auth + + return ( +
+ {/* App Switcher — top of sidebar (scope-level switch) */} +
+ + + + +
+ + {/* Logo — fixed layout, text fades */} + + + Pulse + + + Pulse + + + + {/* Site Picker */} + + + {/* Nav Groups */} + + + {/* Bottom — utility items */} +
+ {/* Notifications, Profile — same layout as nav items */} +
+ + + + + + + router.push('/onboarding')} + allowPersonalOrganization={false} + onOpenSettings={openSettings} + compact + anchor="right" + > + + + +
+ + {/* Settings + Collapse */} +
+ {!isMobile && ( + + )} +
+
+
+ ) +} + // ─── Main Sidebar ─────────────────────────────────────────── export default function Sidebar({ @@ -339,102 +462,6 @@ export default function Sidebar({ const handleNavigate = useCallback((href: string) => { setPendingHref(href) }, []) - const sidebarContent = (isMobile: boolean) => { - const c = isMobile ? false : collapsed - - return ( -
- {/* App Switcher — top of sidebar (scope-level switch) */} -
- - - - -
- - {/* Logo — fixed layout, text fades */} - - - Pulse - - - Pulse - - - - {/* Site Picker */} - - - {/* Nav Groups */} - - - {/* Bottom — utility items */} -
- {/* Notifications, Profile — same layout as nav items */} -
- - - - - - - router.push('/onboarding')} - allowPersonalOrganization={false} - onOpenSettings={openSettings} - compact - anchor="right" - > - - - -
- - {/* Settings + Collapse */} -
- {!isMobile && ( - - )} -
-
-
- ) - } - return ( <> {/* Desktop — ssr:false means this only renders on client, no hydration flash */} @@ -442,7 +469,24 @@ export default function Sidebar({ className="hidden md:flex flex-col shrink-0 border-r border-neutral-800/60 bg-neutral-900/90 backdrop-blur-xl overflow-hidden relative z-10" style={{ width: collapsed ? COLLAPSED : EXPANDED, transition: 'width 200ms cubic-bezier(0.4, 0, 0.2, 1)' }} > - {sidebarContent(false)} + {/* Mobile overlay */} @@ -456,7 +500,24 @@ export default function Sidebar({
- {sidebarContent(true)} + )} From d6cef95c4b9175e983b8db7773ff5dec9dd530da Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 23 Mar 2026 15:19:52 +0100 Subject: [PATCH 35/45] fix(sidebar): dynamic collapse label, favicon fallback, escape key, remove setTimeout hack --- components/dashboard/Sidebar.tsx | 49 ++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 41a35e5..8cbf35a 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -112,9 +112,10 @@ function Label({ children, collapsed }: { children: React.ReactNode; collapsed: // ─── Site Picker ──────────────────────────────────────────── -function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollapsed }: { +function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollapsed, pickerOpenCallback }: { sites: Site[]; siteId: string; collapsed: boolean onExpand: () => void; onCollapse: () => void; wasCollapsed: React.MutableRefObject + pickerOpenCallback: React.MutableRefObject<(() => void) | null> }) { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') @@ -157,9 +158,8 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps onClick={() => { if (collapsed) { wasCollapsed.current = true + pickerOpenCallback.current = () => setOpen(true) onExpand() - // Open picker after sidebar expands - setTimeout(() => setOpen(true), 220) } else { setOpen(!open) } @@ -178,7 +178,11 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps onError={() => setFaviconFailed(true)} /> - ) : null} + ) : ( + + {currentSite?.name?.charAt(0).toUpperCase() || '?'} + + )}