Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions IMPROVEMENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# PingDiff Improvement Log

## 2026-03-20 — Accessibility: ARIA labels, table semantics, and skip navigation

Comprehensive a11y pass across the dashboard, navbar, and secondary pages. The site had no named navigation landmark, no skip links on 3 of 4 pages, tables without column scope attributes, charts completely invisible to assistive technology, and stat cards that conveyed quality purely through color (WCAG 1.4.1 violation). All fixed without new dependencies.

Dashboard: skip link + main-content anchor, role="region" on stats grid, aria-label on each stat card with text description, aria-hidden on decorative icons, role="img" + aria-label on both charts, aria-label on table element, scope="col" on all th elements, time element for timestamps, aria-label on ping/loss cells so quality is communicated in text not just color.

Navbar: aria-label="Main navigation" on nav element, aria-haspopup on mobile toggle, role="menu" on mobile menu container.

Community and Download pages: both were missing skip-to-content links and main-content anchor targets entirely.

**Files changed:** `web/src/app/dashboard/page.tsx`, `web/src/components/Navbar.tsx`, `web/src/app/community/page.tsx`, `web/src/app/download/page.tsx`
**Lines:** +64 / -30

## 2026-03-19 — Performance: Memoize dashboard derived state

All derived values on the dashboard (filteredResults, avgPing, avgPacketLoss, avgJitter, regions, chartData, serverChartData) were being recomputed inline on every React render — including renders triggered by unrelated state changes like the loading flag toggling off. Wrapped each value in useMemo with the tightest possible dependency array, eliminating 5 O(n) reduce passes and 2 groupBy passes on every extraneous render. At current scale the savings are modest; at the 500-1000 result range the dashboard would hit without this change the difference is measurable. The memoized structure also makes data dependencies explicit and auditable at a glance.
Expand Down
5 changes: 4 additions & 1 deletion web/src/app/community/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ import { Footer } from "@/components/Footer";
export default function CommunityPage() {
return (
<div className="min-h-screen">
<a href="#main-content" className="skip-to-content focus-ring">
Skip to main content
</a>
<Navbar />

<main className="max-w-6xl mx-auto px-4 py-16">
<main id="main-content" className="max-w-6xl mx-auto px-4 py-16">
{/* Coming Soon Banner */}
<div className="text-center mb-16">
<div className="inline-flex items-center justify-center w-20 h-20 bg-yellow-500/20 rounded-2xl mb-6">
Expand Down
77 changes: 51 additions & 26 deletions web/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,13 @@ export default function DashboardPage() {

return (
<div className="min-h-screen">
<a href="#main-content" className="skip-to-content focus-ring">
Skip to main content
</a>
<Navbar />

{/* Main Content */}
<main className="max-w-6xl mx-auto px-4 py-8">
<main id="main-content" className="max-w-6xl mx-auto px-4 py-8">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
Expand Down Expand Up @@ -341,23 +344,33 @@ export default function DashboardPage() {
) : (
<>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
<div
className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8"
role="region"
aria-label="Connection statistics summary"
>
<div
className="bg-zinc-900 border border-zinc-800 rounded-xl p-6"
aria-label={`Average ping: ${avgPing} milliseconds — ${getQualityLabel(avgPing)}`}
>
<div className="flex items-center gap-3 mb-2">
<Wifi className="w-5 h-5 text-blue-500" />
<Wifi className="w-5 h-5 text-blue-500" aria-hidden="true" />
<span className="text-zinc-400 text-sm">Average Ping</span>
</div>
<div className={`text-3xl font-bold ${getQualityColor(avgPing)}`}>
<div className={`text-3xl font-bold ${getQualityColor(avgPing)}`} aria-hidden="true">
{avgPing}ms
</div>
<div className="text-sm text-zinc-500">
{getQualityLabel(avgPing)}
</div>
</div>

<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
<div
className="bg-zinc-900 border border-zinc-800 rounded-xl p-6"
aria-label={`Average packet loss: ${avgPacketLoss} percent — ${parseFloat(avgPacketLoss) === 0 ? "No loss" : "Some loss"}`}
>
<div className="flex items-center gap-3 mb-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
<AlertTriangle className="w-5 h-5 text-orange-500" aria-hidden="true" />
<span className="text-zinc-400 text-sm">Packet Loss</span>
</div>
<div
Expand All @@ -366,6 +379,7 @@ export default function DashboardPage() {
? "text-green-500"
: "text-orange-500"
}`}
aria-hidden="true"
>
{avgPacketLoss}%
</div>
Expand All @@ -374,23 +388,29 @@ export default function DashboardPage() {
</div>
</div>

<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
<div
className="bg-zinc-900 border border-zinc-800 rounded-xl p-6"
aria-label={`Average jitter: ${avgJitter} milliseconds`}
>
<div className="flex items-center gap-3 mb-2">
<TrendingDown className="w-5 h-5 text-purple-500" />
<TrendingDown className="w-5 h-5 text-purple-500" aria-hidden="true" />
<span className="text-zinc-400 text-sm">Jitter</span>
</div>
<div className="text-3xl font-bold text-purple-500">
<div className="text-3xl font-bold text-purple-500" aria-hidden="true">
{avgJitter}ms
</div>
<div className="text-sm text-zinc-500">Variation</div>
</div>

<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
<div
className="bg-zinc-900 border border-zinc-800 rounded-xl p-6"
aria-label={`Total tests run: ${filteredResults.length}`}
>
<div className="flex items-center gap-3 mb-2">
<Clock className="w-5 h-5 text-green-500" />
<Clock className="w-5 h-5 text-green-500" aria-hidden="true" />
<span className="text-zinc-400 text-sm">Tests Run</span>
</div>
<div className="text-3xl font-bold text-green-500">
<div className="text-3xl font-bold text-green-500" aria-hidden="true">
{filteredResults.length}
</div>
<div className="text-sm text-zinc-500">Total tests</div>
Expand All @@ -402,7 +422,7 @@ export default function DashboardPage() {
{/* Ping History Chart */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
<h3 className="text-lg font-semibold mb-4">Ping History</h3>
<div className="h-64">
<div className="h-64" role="img" aria-label={`Line chart showing ping history across ${chartData.length} recent tests. Latest ping: ${chartData.at(-1)?.ping ?? 0}ms`}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
Expand Down Expand Up @@ -430,7 +450,7 @@ export default function DashboardPage() {
{/* Server Comparison Chart */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
<h3 className="text-lg font-semibold mb-4">Server Comparison</h3>
<div className="h-64">
<div className="h-64" role="img" aria-label={`Bar chart comparing average ping across ${serverChartData.length} servers. Best server: ${serverChartData[0]?.name ?? "N/A"} at ${serverChartData[0]?.ping ?? 0}ms`}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={serverChartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
Expand Down Expand Up @@ -459,15 +479,18 @@ export default function DashboardPage() {
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<table
className="w-full"
aria-label={`Recent test results — showing ${Math.min(filteredResults.length, 10)} of ${filteredResults.length}`}
>
<thead>
<tr className="text-left text-zinc-400 text-sm">
<th className="pb-4">Server</th>
<th className="pb-4">Ping</th>
<th className="pb-4">Jitter</th>
<th className="pb-4">Loss</th>
<th className="pb-4">ISP</th>
<th className="pb-4">Time</th>
<th scope="col" className="pb-4 font-medium">Server</th>
<th scope="col" className="pb-4 font-medium">Ping</th>
<th scope="col" className="pb-4 font-medium">Jitter</th>
<th scope="col" className="pb-4 font-medium">Loss</th>
<th scope="col" className="pb-4 font-medium">ISP</th>
<th scope="col" className="pb-4 font-medium">Time</th>
</tr>
</thead>
<tbody>
Expand All @@ -482,9 +505,8 @@ export default function DashboardPage() {
</div>
</td>
<td
className={`py-4 font-semibold ${getQualityColor(
result.ping_avg
)}`}
className={`py-4 font-semibold ${getQualityColor(result.ping_avg)}`}
aria-label={`${result.ping_avg} milliseconds — ${getQualityLabel(result.ping_avg)}`}
>
{result.ping_avg}ms
</td>
Expand All @@ -497,14 +519,17 @@ export default function DashboardPage() {
? "text-green-500"
: "text-orange-500"
}`}
aria-label={`${result.packet_loss} percent packet loss${result.packet_loss === 0 ? " — no loss" : ""}`}
>
{result.packet_loss}%
</td>
<td className="py-4 text-zinc-400">
{result.isp || "Unknown"}
</td>
<td className="py-4 text-zinc-500 text-sm">
{new Date(result.created_at).toLocaleDateString()}
<time dateTime={result.created_at}>
{new Date(result.created_at).toLocaleDateString()}
</time>
</td>
</tr>
))}
Expand Down
5 changes: 4 additions & 1 deletion web/src/app/download/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,13 @@ export default function DownloadPage() {

return (
<div className="min-h-screen">
<a href="#main-content" className="skip-to-content focus-ring">
Skip to main content
</a>
<Navbar />

{/* Main Content */}
<main className="max-w-4xl mx-auto px-4 py-16">
<main id="main-content" className="max-w-4xl mx-auto px-4 py-16">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold mb-4">Download PingDiff</h1>
<p className="text-zinc-400 text-lg">
Expand Down
7 changes: 5 additions & 2 deletions web/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function Navbar() {
const isDownload = (href: string) => href === "/download";

return (
<nav className="border-b border-zinc-800 bg-zinc-950/80 backdrop-blur-sm sticky top-0 z-50">
<nav className="border-b border-zinc-800 bg-zinc-950/80 backdrop-blur-sm sticky top-0 z-50" aria-label="Main navigation">
<div className="max-w-6xl mx-auto px-4 py-4 flex justify-between items-center">
<Link href="/" className="flex items-center gap-2 focus-ring rounded-lg">
<Activity className="w-7 h-7 md:w-8 md:h-8 text-blue-500" />
Expand All @@ -35,9 +35,10 @@ export function Navbar() {
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden p-2 text-zinc-400 hover:text-white transition focus-ring rounded-lg"
aria-label={mobileMenuOpen ? "Close menu" : "Open menu"}
aria-label={mobileMenuOpen ? "Close navigation menu" : "Open navigation menu"}
aria-expanded={mobileMenuOpen}
aria-controls="mobile-menu"
aria-haspopup="true"
>
{mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
Expand Down Expand Up @@ -72,6 +73,8 @@ export function Navbar() {
{mobileMenuOpen && (
<div
id="mobile-menu"
role="menu"
aria-label="Navigation menu"
className="md:hidden border-t border-zinc-800 bg-zinc-950 fade-in"
>
<div className="px-4 py-4 flex flex-col gap-4">
Expand Down
Loading