feat: enhance PerformanceStats component with overall performance scoring and improved UI for metric display

This commit is contained in:
Usman Baig
2026-01-19 18:15:45 +01:00
parent d37b706751
commit 78d348f037

View File

@@ -35,13 +35,33 @@ function MetricCard({ label, value, unit, score }: { label: string, value: numbe
export default function PerformanceStats({ stats, performanceByPage, siteId, startDate, endDate, getPerformanceByPage }: Props) { export default function PerformanceStats({ stats, performanceByPage, siteId, startDate, endDate, getPerformanceByPage }: Props) {
// * Scoring Logic (based on Google Web Vitals) // * Scoring Logic (based on Google Web Vitals)
const getScore = (metric: 'lcp' | 'cls' | 'inp', value: number) => { type Score = 'good' | 'needs-improvement' | 'poor'
const getScore = (metric: 'lcp' | 'cls' | 'inp', value: number): Score => {
if (metric === 'lcp') return value <= 2500 ? 'good' : value <= 4000 ? 'needs-improvement' : 'poor' if (metric === 'lcp') return value <= 2500 ? 'good' : value <= 4000 ? 'needs-improvement' : 'poor'
if (metric === 'cls') return value <= 0.1 ? 'good' : value <= 0.25 ? 'needs-improvement' : 'poor' if (metric === 'cls') return value <= 0.1 ? 'good' : value <= 0.25 ? 'needs-improvement' : 'poor'
if (metric === 'inp') return value <= 200 ? 'good' : value <= 500 ? 'needs-improvement' : 'poor' if (metric === 'inp') return value <= 200 ? 'good' : value <= 500 ? 'needs-improvement' : 'poor'
return 'good' return 'good'
} }
// * Overall performance: worst of LCP, CLS, INP (matches Googles “field” rating)
const getOverallScore = (s: { lcp: number; cls: number; inp: number }): Score => {
const lcp = getScore('lcp', s.lcp)
const cls = getScore('cls', s.cls)
const inp = getScore('inp', s.inp)
if (lcp === 'poor' || cls === 'poor' || inp === 'poor') return 'poor'
if (lcp === 'needs-improvement' || cls === 'needs-improvement' || inp === 'needs-improvement') return 'needs-improvement'
return 'good'
}
const overallScore = getOverallScore(stats)
const overallLabel = { good: 'Good', 'needs-improvement': 'Needs improvement', poor: 'Poor' }[overallScore]
const overallBadgeClass = {
good: 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 border-green-200 dark:border-green-800',
'needs-improvement': 'text-yellow-700 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800',
poor: 'text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30 border-red-200 dark:border-red-800',
}[overallScore]
const [mainExpanded, setMainExpanded] = useState(false)
const [sortBy, setSortBy] = useState<'lcp' | 'cls' | 'inp'>('lcp') const [sortBy, setSortBy] = useState<'lcp' | 'cls' | 'inp'>('lcp')
const [overrideRows, setOverrideRows] = useState<PerformanceByPageStat[] | null>(null) const [overrideRows, setOverrideRows] = useState<PerformanceByPageStat[] | null>(null)
const [loadingTable, setLoadingTable] = useState(false) const [loadingTable, setLoadingTable] = useState(false)
@@ -85,11 +105,40 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
return cellScoreClass(getScore(metric, val)) return cellScoreClass(getScore(metric, val))
} }
const summaryText = `LCP ${Math.round(stats.lcp)} ms · CLS ${Number(stats.cls.toFixed(3))} · INP ${Math.round(stats.inp)} ms`
return ( return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-4"> {/* * One-line summary: Performance score + metric summary. Click to expand. */}
Core Web Vitals <button
</h3> type="button"
onClick={() => setMainExpanded((o) => !o)}
className="flex w-full items-center justify-between gap-4 text-left rounded cursor-pointer hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-neutral-400 dark:focus:ring-neutral-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-900"
aria-expanded={mainExpanded}
>
<div className="flex items-center gap-2 min-w-0">
<ChevronDownIcon
className={`w-4 h-4 shrink-0 text-neutral-500 transition-transform ${mainExpanded ? '' : '-rotate-90'}`}
aria-hidden
/>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Performance</span>
<span className={`shrink-0 rounded-md border px-2 py-0.5 text-xs font-medium ${overallBadgeClass}`}>
{overallLabel}
</span>
</div>
<span className="text-xs text-neutral-500 truncate" title={summaryText}>
{summaryText}
</span>
</button>
{/* * Expanded: full LCP/CLS/INP cards, footnote, and Worst pages (collapsible) */}
<motion.div
initial={false}
animate={{ height: mainExpanded ? 'auto' : 0, opacity: mainExpanded ? 1 : 0 }}
transition={{ duration: 0.25, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard <MetricCard
label="Largest Contentful Paint (LCP)" label="Largest Contentful Paint (LCP)"
@@ -114,7 +163,7 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
* Averages calculated from real user sessions. Lower is better. * Averages calculated from real user sessions. Lower is better.
</div> </div>
{/* * Performance by page (worst first) collapsed by default */} {/* * Worst pages by metric collapsed by default */}
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800"> <div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800">
<div className="flex items-center justify-between gap-4 mb-3"> <div className="flex items-center justify-between gap-4 mb-3">
<button <button
@@ -198,5 +247,7 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
</motion.div> </motion.div>
</div> </div>
</div> </div>
</motion.div>
</div>
) )
} }