diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md
index 7e53510..64b0225 100644
--- a/IMPROVEMENTS.md
+++ b/IMPROVEMENTS.md
@@ -1,5 +1,33 @@
# PingDiff Improvement Log
+## 2026-03-20 — Bug Fix: Per-page metadata for dashboard, download, and community
+
+All three main pages used `"use client"` at the top level, which blocks Next.js from reading the
+`metadata` export. Every page fell back to the generic homepage title and description — so sharing
+any page on Discord, Twitter, or iMessage showed "PingDiff - Test Your Game Server Connection"
+regardless of which page it was. The `template: "%s | PingDiff"` in `layout.tsx` was dead code.
+
+Fixed by splitting each page into a thin server wrapper (`page.tsx`, exports metadata) and a
+client component (`*Client.tsx`, holds all interactive state). No logic changed — pure structural
+refactor following the standard App Router pattern. Each page now has a distinct title,
+description, and og:title/og:description for accurate social previews.
+
+**Files changed:** `web/src/app/dashboard/page.tsx`, `web/src/app/dashboard/DashboardClient.tsx` (new), `web/src/app/download/page.tsx`, `web/src/app/download/DownloadClient.tsx` (new), `web/src/app/community/page.tsx`, `web/src/app/community/CommunityClient.tsx` (new)
+**Lines:** +934 / -890
+
+## 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.
diff --git a/web/src/app/community/CommunityClient.tsx b/web/src/app/community/CommunityClient.tsx
new file mode 100644
index 0000000..d2d5d54
--- /dev/null
+++ b/web/src/app/community/CommunityClient.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { MessageSquare, ThumbsUp, Users, Construction } from "lucide-react";
+import { Navbar } from "@/components/Navbar";
+import { Footer } from "@/components/Footer";
+
+export default function CommunityClient() {
+ return (
+
+
+ Skip to main content
+
+
+
+
+ {/* Coming Soon Banner */}
+
+
+
+
+
Community Hub
+
+ Share tips, compare results, and help other players find the best servers.
+
+
+ Coming Soon
+
+
+
+ {/* Preview Features */}
+
+
+
+
+
+
ISP Tips
+
+ Share and discover tips for optimizing your connection based on your ISP and region.
+
+
+
+
+
+
+
+
Upvote System
+
+ Vote on the most helpful tips to surface the best advice for each region.
+
+
+
+
+
+
+
+
Leaderboards
+
+ See the best ping results by region, ISP, and server location.
+
+
+
+
+ {/* CTA */}
+
+
Want to be notified when Community launches?
+
+ Star our GitHub repo to get updates on new features.
+
+
+ Star on GitHub
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/community/page.tsx b/web/src/app/community/page.tsx
index cd6b1ca..b7105b7 100644
--- a/web/src/app/community/page.tsx
+++ b/web/src/app/community/page.tsx
@@ -1,83 +1,15 @@
-"use client";
-
-import { MessageSquare, ThumbsUp, Users, Construction } from "lucide-react";
-import { Navbar } from "@/components/Navbar";
-import { Footer } from "@/components/Footer";
+import type { Metadata } from "next";
+import CommunityClient from "./CommunityClient";
+
+export const metadata: Metadata = {
+ title: "Community",
+ description: "Share connection tips, compare ping results with other players, and find the best game servers for your region.",
+ openGraph: {
+ title: "PingDiff Community Hub",
+ description: "Share connection tips, compare ping results with other players, and find the best game servers for your region.",
+ },
+};
export default function CommunityPage() {
- return (
-
-
- Skip to main content
-
-
-
-
- {/* Coming Soon Banner */}
-
-
-
-
-
Community Hub
-
- Share tips, compare results, and help other players find the best servers.
-
-
- Coming Soon
-
-
-
- {/* Preview Features */}
-
-
-
-
-
-
ISP Tips
-
- Share and discover tips for optimizing your connection based on your ISP and region.
-
-
-
-
-
-
-
-
Upvote System
-
- Vote on the most helpful tips to surface the best advice for each region.
-
-
-
-
-
-
-
-
Leaderboards
-
- See the best ping results by region, ISP, and server location.
-
-
-
-
- {/* CTA */}
-
-
Want to be notified when Community launches?
-
- Star our GitHub repo to get updates on new features.
-
-
- Star on GitHub
-
-
-
-
-
-
- );
+ return ;
}
diff --git a/web/src/app/dashboard/DashboardClient.tsx b/web/src/app/dashboard/DashboardClient.tsx
new file mode 100644
index 0000000..b75ef54
--- /dev/null
+++ b/web/src/app/dashboard/DashboardClient.tsx
@@ -0,0 +1,547 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import Link from "next/link";
+import {
+ Download,
+ Clock,
+ Server,
+ Wifi,
+ TrendingDown,
+ AlertTriangle,
+ RefreshCw,
+ AlertCircle,
+ FileDown,
+} from "lucide-react";
+import { Navbar } from "@/components/Navbar";
+import { Footer } from "@/components/Footer";
+import { DashboardSkeleton } from "@/components/DashboardSkeleton";
+import {
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ BarChart,
+ Bar,
+} from "recharts";
+
+interface TestResult {
+ id: string;
+ ping_avg: number;
+ ping_min: number;
+ ping_max: number;
+ jitter: number;
+ packet_loss: number;
+ isp: string;
+ country: string;
+ city: string;
+ created_at: string;
+ game_servers: {
+ location: string;
+ region: string;
+ };
+}
+
+type DateRange = "7" | "30" | "90" | "all";
+
+const DATE_RANGE_OPTIONS: { value: DateRange; label: string }[] = [
+ { value: "7", label: "Last 7 days" },
+ { value: "30", label: "Last 30 days" },
+ { value: "90", label: "Last 90 days" },
+ { value: "all", label: "All time" },
+];
+
+function exportToCSV(results: TestResult[]) {
+ const headers = [
+ "Date",
+ "Server",
+ "Region",
+ "Avg Ping (ms)",
+ "Min Ping (ms)",
+ "Max Ping (ms)",
+ "Jitter (ms)",
+ "Packet Loss (%)",
+ "ISP",
+ "Country",
+ "City",
+ ];
+
+ const rows = results.map((r) => [
+ new Date(r.created_at).toISOString(),
+ r.game_servers?.location ?? "Unknown",
+ r.game_servers?.region ?? "",
+ r.ping_avg,
+ r.ping_min,
+ r.ping_max,
+ r.jitter?.toFixed(2) ?? "0",
+ r.packet_loss,
+ r.isp ?? "Unknown",
+ r.country ?? "",
+ r.city ?? "",
+ ]);
+
+ const csvContent = [headers, ...rows]
+ .map((row) =>
+ row
+ .map((cell) => {
+ const str = String(cell);
+ // Wrap in quotes if contains comma, quote, or newline
+ if (str.includes(",") || str.includes('"') || str.includes("\n")) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+ })
+ .join(",")
+ )
+ .join("\n");
+
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `pingdiff-results-${new Date().toISOString().slice(0, 10)}.csv`;
+ link.click();
+ URL.revokeObjectURL(url);
+}
+
+export default function DashboardClient() {
+ const [results, setResults] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [selectedRegion, setSelectedRegion] = useState("all");
+ const [dateRange, setDateRange] = useState("30");
+
+ useEffect(() => {
+ fetchResults();
+ }, []);
+
+ const fetchResults = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await fetch("/api/results?limit=100");
+ if (!response.ok) {
+ throw new Error(`Failed to load results (${response.status})`);
+ }
+ const data = await response.json();
+ setResults(data);
+ } catch (err) {
+ console.error("Failed to fetch results:", err);
+ setError(err instanceof Error ? err.message : "Failed to load results. Please try again.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Apply date range filter
+ const applyDateFilter = useCallback(
+ (data: TestResult[]): TestResult[] => {
+ if (dateRange === "all") return data;
+ const cutoff = new Date();
+ cutoff.setDate(cutoff.getDate() - parseInt(dateRange));
+ return data.filter((r) => new Date(r.created_at) >= cutoff);
+ },
+ [dateRange]
+ );
+
+ // Apply region filter on top of date filter
+ const dateFiltered = applyDateFilter(results);
+ const filteredResults =
+ selectedRegion === "all"
+ ? dateFiltered
+ : dateFiltered.filter((r) => r.game_servers?.region === selectedRegion);
+
+ const avgPing =
+ filteredResults.length > 0
+ ? Math.round(
+ filteredResults.reduce((sum, r) => sum + r.ping_avg, 0) /
+ filteredResults.length
+ )
+ : 0;
+
+ const avgPacketLoss =
+ filteredResults.length > 0
+ ? (
+ filteredResults.reduce((sum, r) => sum + r.packet_loss, 0) /
+ filteredResults.length
+ ).toFixed(1)
+ : "0";
+
+ const avgJitter =
+ filteredResults.length > 0
+ ? (
+ filteredResults.reduce((sum, r) => sum + (r.jitter || 0), 0) /
+ filteredResults.length
+ ).toFixed(1)
+ : "0";
+
+ // Get unique regions (from all results, not filtered)
+ const regions = [
+ ...new Set(results.map((r) => r.game_servers?.region).filter(Boolean)),
+ ];
+
+ // Prepare chart data
+ const chartData = filteredResults.slice(0, 20).reverse().map((r, i) => ({
+ name: `Test ${i + 1}`,
+ ping: r.ping_avg,
+ jitter: r.jitter || 0,
+ loss: r.packet_loss,
+ }));
+
+ // Server comparison data
+ const serverData = filteredResults.reduce((acc, r) => {
+ const location = r.game_servers?.location || "Unknown";
+ if (!acc[location]) {
+ acc[location] = { pings: [], location };
+ }
+ acc[location].pings.push(r.ping_avg);
+ return acc;
+ }, {} as Record);
+
+ const serverChartData = Object.values(serverData)
+ .map((s) => ({
+ name: s.location.split(" ")[0],
+ ping: Math.round(s.pings.reduce((a, b) => a + b, 0) / s.pings.length),
+ }))
+ .sort((a, b) => a.ping - b.ping);
+
+ const getQualityColor = (ping: number) => {
+ if (ping < 30) return "text-green-500";
+ if (ping < 60) return "text-green-400";
+ if (ping < 100) return "text-yellow-500";
+ if (ping < 150) return "text-orange-500";
+ return "text-red-500";
+ };
+
+ const getQualityLabel = (ping: number) => {
+ if (ping < 30) return "Excellent";
+ if (ping < 60) return "Good";
+ if (ping < 100) return "Fair";
+ if (ping < 150) return "Poor";
+ return "Bad";
+ };
+
+ return (
+
+
+ Skip to main content
+
+
+
+ {/* Main Content */}
+
+
+
+
Dashboard
+
Your connection test results
+
+
+ {/* Filters */}
+
+ {/* Date range filter */}
+ setDateRange(e.target.value as DateRange)}
+ className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus-ring"
+ aria-label="Filter results by date range"
+ >
+ {DATE_RANGE_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ {/* Region filter */}
+ setSelectedRegion(e.target.value)}
+ className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus-ring"
+ aria-label="Filter results by region"
+ >
+ All Regions
+ {regions.map((region) => (
+
+ {region}
+
+ ))}
+
+
+ {/* Export CSV button */}
+ {filteredResults.length > 0 && (
+ exportToCSV(filteredResults)}
+ className="inline-flex items-center gap-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 px-3 py-2 rounded-lg text-sm font-medium transition focus-ring"
+ aria-label={`Export ${filteredResults.length} results to CSV`}
+ title="Export filtered results as CSV"
+ >
+
+ Export CSV
+
+ )}
+
+ {/* Refresh */}
+
+
+ Refresh
+
+
+
+
+ {loading ? (
+
+ ) : error ? (
+
+
+
Failed to Load Results
+
{error}
+
+
+ Try Again
+
+
+ ) : results.length === 0 ? (
+
+
+
No Results Yet
+
+ Download the app and run your first test to see results here.
+
+
+
+ Download PingDiff
+
+
+ ) : filteredResults.length === 0 ? (
+
+
+
No Results in This Range
+
+ No tests found for the selected filters. Try a wider date range or different region.
+
+
{ setDateRange("all"); setSelectedRegion("all"); }}
+ className="inline-flex items-center gap-2 bg-zinc-700 hover:bg-zinc-600 px-6 py-3 rounded-lg font-medium transition"
+ >
+ Clear Filters
+
+
+ ) : (
+ <>
+ {/* Stats Cards */}
+
+
+
+
+ Average Ping
+
+
+ {avgPing}ms
+
+
+ {getQualityLabel(avgPing)}
+
+
+
+
+
+
+ {avgPacketLoss}%
+
+
+ {parseFloat(avgPacketLoss) === 0 ? "No loss" : "Some loss"}
+
+
+
+
+
+
+ Jitter
+
+
+ {avgJitter}ms
+
+
Variation
+
+
+
+
+
+ Tests Run
+
+
+ {filteredResults.length}
+
+
Total tests
+
+
+
+ {/* Charts */}
+
+ {/* Ping History Chart */}
+
+
Ping History
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Server Comparison Chart */}
+
+
Server Comparison
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Recent Results Table */}
+
+
+
Recent Tests
+
+ Showing {Math.min(filteredResults.length, 10)} of {filteredResults.length}
+
+
+
+
+
+
+ Server
+ Ping
+ Jitter
+ Loss
+ ISP
+ Time
+
+
+
+ {filteredResults.slice(0, 10).map((result) => (
+
+
+
+ {result.game_servers?.location || "Unknown"}
+
+
+ {result.game_servers?.region}
+
+
+
+ {result.ping_avg}ms
+
+
+ {result.jitter?.toFixed(1) || "0"}ms
+
+
+ {result.packet_loss}%
+
+
+ {result.isp || "Unknown"}
+
+
+
+ {new Date(result.created_at).toLocaleDateString()}
+
+
+
+ ))}
+
+
+
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx
index 51850c6..1808b47 100644
--- a/web/src/app/dashboard/page.tsx
+++ b/web/src/app/dashboard/page.tsx
@@ -1,567 +1,15 @@
-"use client";
-
-import { useState, useEffect, useCallback, useMemo } from "react";
-import Link from "next/link";
-import {
- Download,
- Clock,
- Server,
- Wifi,
- TrendingDown,
- AlertTriangle,
- RefreshCw,
- AlertCircle,
- FileDown,
-} from "lucide-react";
-import { Navbar } from "@/components/Navbar";
-import { Footer } from "@/components/Footer";
-import { DashboardSkeleton } from "@/components/DashboardSkeleton";
-import {
- LineChart,
- Line,
- XAxis,
- YAxis,
- CartesianGrid,
- Tooltip,
- ResponsiveContainer,
- BarChart,
- Bar,
-} from "recharts";
-
-interface TestResult {
- id: string;
- ping_avg: number;
- ping_min: number;
- ping_max: number;
- jitter: number;
- packet_loss: number;
- isp: string;
- country: string;
- city: string;
- created_at: string;
- game_servers: {
- location: string;
- region: string;
- };
-}
-
-type DateRange = "7" | "30" | "90" | "all";
-
-const DATE_RANGE_OPTIONS: { value: DateRange; label: string }[] = [
- { value: "7", label: "Last 7 days" },
- { value: "30", label: "Last 30 days" },
- { value: "90", label: "Last 90 days" },
- { value: "all", label: "All time" },
-];
-
-function exportToCSV(results: TestResult[]) {
- const headers = [
- "Date",
- "Server",
- "Region",
- "Avg Ping (ms)",
- "Min Ping (ms)",
- "Max Ping (ms)",
- "Jitter (ms)",
- "Packet Loss (%)",
- "ISP",
- "Country",
- "City",
- ];
-
- const rows = results.map((r) => [
- new Date(r.created_at).toISOString(),
- r.game_servers?.location ?? "Unknown",
- r.game_servers?.region ?? "",
- r.ping_avg,
- r.ping_min,
- r.ping_max,
- r.jitter?.toFixed(2) ?? "0",
- r.packet_loss,
- r.isp ?? "Unknown",
- r.country ?? "",
- r.city ?? "",
- ]);
-
- const csvContent = [headers, ...rows]
- .map((row) =>
- row
- .map((cell) => {
- const str = String(cell);
- // Wrap in quotes if contains comma, quote, or newline
- if (str.includes(",") || str.includes('"') || str.includes("\n")) {
- return `"${str.replace(/"/g, '""')}"`;
- }
- return str;
- })
- .join(",")
- )
- .join("\n");
-
- const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
- const url = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = `pingdiff-results-${new Date().toISOString().slice(0, 10)}.csv`;
- link.click();
- URL.revokeObjectURL(url);
-}
+import type { Metadata } from "next";
+import DashboardClient from "./DashboardClient";
+
+export const metadata: Metadata = {
+ title: "Dashboard",
+ description: "View your ping test results, packet loss trends, and server comparisons. Analyze your connection history across all game servers.",
+ openGraph: {
+ title: "PingDiff Dashboard — Your Connection Test Results",
+ description: "View your ping test results, packet loss trends, and server comparisons.",
+ },
+};
export default function DashboardPage() {
- const [results, setResults] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [selectedRegion, setSelectedRegion] = useState("all");
- const [dateRange, setDateRange] = useState("30");
-
- useEffect(() => {
- fetchResults();
- }, []);
-
- const fetchResults = async () => {
- setLoading(true);
- setError(null);
- try {
- const response = await fetch("/api/results?limit=100");
- if (!response.ok) {
- throw new Error(`Failed to load results (${response.status})`);
- }
- const data = await response.json();
- setResults(data);
- } catch (err) {
- console.error("Failed to fetch results:", err);
- setError(err instanceof Error ? err.message : "Failed to load results. Please try again.");
- } finally {
- setLoading(false);
- }
- };
-
- // Apply date range filter
- const applyDateFilter = useCallback(
- (data: TestResult[]): TestResult[] => {
- if (dateRange === "all") return data;
- const cutoff = new Date();
- cutoff.setDate(cutoff.getDate() - parseInt(dateRange));
- return data.filter((r) => new Date(r.created_at) >= cutoff);
- },
- [dateRange]
- );
-
- // Apply date + region filters — memoized so they only recompute when deps change
- const filteredResults = useMemo(() => {
- const dateFiltered = applyDateFilter(results);
- return selectedRegion === "all"
- ? dateFiltered
- : dateFiltered.filter((r) => r.game_servers?.region === selectedRegion);
- }, [results, applyDateFilter, selectedRegion]);
-
- // Aggregate stats — memoized on filteredResults
- const avgPing = useMemo(
- () =>
- filteredResults.length > 0
- ? Math.round(
- filteredResults.reduce((sum, r) => sum + r.ping_avg, 0) /
- filteredResults.length
- )
- : 0,
- [filteredResults]
- );
-
- const avgPacketLoss = useMemo(
- () =>
- filteredResults.length > 0
- ? (
- filteredResults.reduce((sum, r) => sum + r.packet_loss, 0) /
- filteredResults.length
- ).toFixed(1)
- : "0",
- [filteredResults]
- );
-
- const avgJitter = useMemo(
- () =>
- filteredResults.length > 0
- ? (
- filteredResults.reduce((sum, r) => sum + (r.jitter || 0), 0) /
- filteredResults.length
- ).toFixed(1)
- : "0",
- [filteredResults]
- );
-
- // Unique regions derived from all results (unaffected by filters)
- const regions = useMemo(
- () => [
- ...new Set(results.map((r) => r.game_servers?.region).filter(Boolean)),
- ],
- [results]
- );
-
- // Ping history chart data
- const chartData = useMemo(
- () =>
- filteredResults.slice(0, 20).reverse().map((r, i) => ({
- name: `Test ${i + 1}`,
- ping: r.ping_avg,
- jitter: r.jitter || 0,
- loss: r.packet_loss,
- })),
- [filteredResults]
- );
-
- // Server comparison chart data — group by location, sort by avg ping
- const serverChartData = useMemo(() => {
- const serverData = filteredResults.reduce((acc, r) => {
- const location = r.game_servers?.location || "Unknown";
- if (!acc[location]) {
- acc[location] = { pings: [], location };
- }
- acc[location].pings.push(r.ping_avg);
- return acc;
- }, {} as Record);
-
- return Object.values(serverData)
- .map((s) => ({
- name: s.location.split(" ")[0],
- ping: Math.round(s.pings.reduce((a, b) => a + b, 0) / s.pings.length),
- }))
- .sort((a, b) => a.ping - b.ping);
- }, [filteredResults]);
-
- const getQualityColor = (ping: number) => {
- if (ping < 30) return "text-green-500";
- if (ping < 60) return "text-green-400";
- if (ping < 100) return "text-yellow-500";
- if (ping < 150) return "text-orange-500";
- return "text-red-500";
- };
-
- const getQualityLabel = (ping: number) => {
- if (ping < 30) return "Excellent";
- if (ping < 60) return "Good";
- if (ping < 100) return "Fair";
- if (ping < 150) return "Poor";
- return "Bad";
- };
-
- return (
-
-
- Skip to main content
-
-
-
- {/* Main Content */}
-
-
-
-
Dashboard
-
Your connection test results
-
-
- {/* Filters */}
-
- {/* Date range filter */}
- setDateRange(e.target.value as DateRange)}
- className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus-ring"
- aria-label="Filter results by date range"
- >
- {DATE_RANGE_OPTIONS.map((opt) => (
-
- {opt.label}
-
- ))}
-
-
- {/* Region filter */}
- setSelectedRegion(e.target.value)}
- className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus-ring"
- aria-label="Filter results by region"
- >
- All Regions
- {regions.map((region) => (
-
- {region}
-
- ))}
-
-
- {/* Export CSV button */}
- {filteredResults.length > 0 && (
- exportToCSV(filteredResults)}
- className="inline-flex items-center gap-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 px-3 py-2 rounded-lg text-sm font-medium transition focus-ring"
- aria-label={`Export ${filteredResults.length} results to CSV`}
- title="Export filtered results as CSV"
- >
-
- Export CSV
-
- )}
-
- {/* Refresh */}
-
-
- Refresh
-
-
-
-
- {loading ? (
-
- ) : error ? (
-
-
-
Failed to Load Results
-
{error}
-
-
- Try Again
-
-
- ) : results.length === 0 ? (
-
-
-
No Results Yet
-
- Download the app and run your first test to see results here.
-
-
-
- Download PingDiff
-
-
- ) : filteredResults.length === 0 ? (
-
-
-
No Results in This Range
-
- No tests found for the selected filters. Try a wider date range or different region.
-
-
{ setDateRange("all"); setSelectedRegion("all"); }}
- className="inline-flex items-center gap-2 bg-zinc-700 hover:bg-zinc-600 px-6 py-3 rounded-lg font-medium transition"
- >
- Clear Filters
-
-
- ) : (
- <>
- {/* Stats Cards */}
-
-
-
-
- Average Ping
-
-
- {avgPing}ms
-
-
- {getQualityLabel(avgPing)}
-
-
-
-
-
-
- {avgPacketLoss}%
-
-
- {parseFloat(avgPacketLoss) === 0 ? "No loss" : "Some loss"}
-
-
-
-
-
-
- Jitter
-
-
- {avgJitter}ms
-
-
Variation
-
-
-
-
-
- Tests Run
-
-
- {filteredResults.length}
-
-
Total tests
-
-
-
- {/* Charts */}
-
- {/* Ping History Chart */}
-
-
Ping History
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Server Comparison Chart */}
-
-
Server Comparison
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Recent Results Table */}
-
-
-
Recent Tests
-
- Showing {Math.min(filteredResults.length, 10)} of {filteredResults.length}
-
-
-
-
-
-
- Server
- Ping
- Jitter
- Loss
- ISP
- Time
-
-
-
- {filteredResults.slice(0, 10).map((result) => (
-
-
-
- {result.game_servers?.location || "Unknown"}
-
-
- {result.game_servers?.region}
-
-
-
- {result.ping_avg}ms
-
-
- {result.jitter?.toFixed(1) || "0"}ms
-
-
- {result.packet_loss}%
-
-
- {result.isp || "Unknown"}
-
-
-
- {new Date(result.created_at).toLocaleDateString()}
-
-
-
- ))}
-
-
-
-
- >
- )}
-
-
-
-
- );
+ return ;
}
diff --git a/web/src/app/download/DownloadClient.tsx b/web/src/app/download/DownloadClient.tsx
new file mode 100644
index 0000000..5cf3a1e
--- /dev/null
+++ b/web/src/app/download/DownloadClient.tsx
@@ -0,0 +1,268 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Activity, Download, Shield, Settings, CheckCircle, Loader2, FolderOpen, AlertCircle, Github } from "lucide-react";
+import { Navbar } from "@/components/Navbar";
+import { Footer } from "@/components/Footer";
+
+interface ReleaseInfo {
+ version: string;
+ downloadUrl: string;
+ size: string;
+ date: string;
+ isInstaller: boolean;
+}
+
+export default function DownloadClient() {
+ const [release, setRelease] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [usingFallback, setUsingFallback] = useState(false);
+
+ useEffect(() => {
+ // Fetch latest release from GitHub API
+ fetch("https://api.github.com/repos/bokiko/pingdiff/releases/latest")
+ .then(res => {
+ if (!res.ok) throw new Error("API error");
+ return res.json();
+ })
+ .then(data => {
+ const asset = data.assets?.find((a: { name: string }) => a.name.endsWith('.exe'));
+ if (asset) {
+ const isInstaller = asset.name.includes('Setup');
+ const sizeInMB = (asset.size / (1024 * 1024)).toFixed(1);
+ setRelease({
+ version: data.tag_name || "v1.17.1",
+ downloadUrl: asset.browser_download_url,
+ size: `${sizeInMB} MB`,
+ date: new Date(data.published_at).toLocaleDateString(),
+ isInstaller
+ });
+ } else {
+ throw new Error("No exe found");
+ }
+ setLoading(false);
+ })
+ .catch(() => {
+ // Fallback - still provide download link
+ setRelease({
+ version: "v1.17.1",
+ downloadUrl: "https://github.com/bokiko/pingdiff/releases/latest",
+ size: "~11 MB",
+ date: "",
+ isInstaller: true
+ });
+ setUsingFallback(true);
+ setLoading(false);
+ });
+ }, []);
+
+ const getFileName = () => {
+ if (!release) return "PingDiff";
+ const version = release.version.replace('v', '');
+ return release.isInstaller ? `PingDiff-Setup-${version}.exe` : `PingDiff-${release.version}.exe`;
+ };
+
+ return (
+
+
+ Skip to main content
+
+
+
+ {/* Main Content */}
+
+
+
Download PingDiff
+
+ Get the desktop app to test your connection to game servers
+
+
+
+ {/* Fallback Notice */}
+ {usingFallback && (
+
+
+
+ Couldn't fetch latest version info. Showing fallback download link.
+ Check GitHub Releases for the latest version.
+
+
+ )}
+
+ {/* Download Card */}
+
+
+ {/* Features */}
+
+
+
+
+
+
Safe & Open Source
+
+ 100% open source. No malware, no tracking, no ads. Check the code yourself on GitHub or GitLab.
+
+
+
+
+
+
+
+
Your Privacy, Your Choice
+
+ Toggle result sharing on or off. Your settings are saved locally and persist across updates.
+
+
+
+
+
+
+
+
Clean Install & Updates
+
+ Proper Windows installer. Updates automatically clean up old versions while preserving your data.
+
+
+
+
+ {/* System Requirements */}
+
+
System Requirements
+
+
+
+ Windows 10 or Windows 11
+
+
+
+ Internet connection
+
+
+
+ 50MB free disk space
+
+
+
+ Admin rights for installation
+
+
+
+
+ {/* Data Storage Info */}
+
+
+
+ Where is my data stored?
+
+
+
Program files: C:\Program Files\PingDiff
+
Settings & logs: %APPDATA%\PingDiff
+
Your settings and logs are preserved when you update to a new version.
+
+
+
+ {/* Other Platforms */}
+
+
Other Platforms
+
+ Mac and Linux versions coming soon. Want to be notified?
+
+
+
+ macOS - Coming Soon
+
+
+ Linux - Coming Soon
+
+
+
+
+ {/* Instructions */}
+
+
How to Use
+
+
+
+ 1
+
+
Download & run the installer
+
+
+
+ 2
+
+
Launch PingDiff from Start Menu
+
+
+
+ 3
+
+
Select your region
+
+
+
+ 4
+
+
Click Test and see results
+
+
+
+
+ {/* All Releases Links */}
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/download/page.tsx b/web/src/app/download/page.tsx
index 8c3d4b7..cb03c09 100644
--- a/web/src/app/download/page.tsx
+++ b/web/src/app/download/page.tsx
@@ -1,269 +1,15 @@
-"use client";
-
-import { useState } from "react";
-import { Activity, Download, Shield, Settings, CheckCircle, Loader2, FolderOpen, AlertCircle, Github } from "lucide-react";
-import { useEffect } from "react";
-import { Navbar } from "@/components/Navbar";
-import { Footer } from "@/components/Footer";
-
-interface ReleaseInfo {
- version: string;
- downloadUrl: string;
- size: string;
- date: string;
- isInstaller: boolean;
-}
+import type { Metadata } from "next";
+import DownloadClient from "./DownloadClient";
+
+export const metadata: Metadata = {
+ title: "Download",
+ description: "Download PingDiff for Windows — free, open source desktop app to test your ping and packet loss to game servers before you queue.",
+ openGraph: {
+ title: "Download PingDiff for Windows",
+ description: "Free, open source desktop app to test your ping and packet loss to game servers before you queue.",
+ },
+};
export default function DownloadPage() {
- const [release, setRelease] = useState(null);
- const [loading, setLoading] = useState(true);
- const [usingFallback, setUsingFallback] = useState(false);
-
- useEffect(() => {
- // Fetch latest release from GitHub API
- fetch("https://api.github.com/repos/bokiko/pingdiff/releases/latest")
- .then(res => {
- if (!res.ok) throw new Error("API error");
- return res.json();
- })
- .then(data => {
- const asset = data.assets?.find((a: { name: string }) => a.name.endsWith('.exe'));
- if (asset) {
- const isInstaller = asset.name.includes('Setup');
- const sizeInMB = (asset.size / (1024 * 1024)).toFixed(1);
- setRelease({
- version: data.tag_name || "v1.17.1",
- downloadUrl: asset.browser_download_url,
- size: `${sizeInMB} MB`,
- date: new Date(data.published_at).toLocaleDateString(),
- isInstaller
- });
- } else {
- throw new Error("No exe found");
- }
- setLoading(false);
- })
- .catch(() => {
- // Fallback - still provide download link
- setRelease({
- version: "v1.17.1",
- downloadUrl: "https://github.com/bokiko/pingdiff/releases/latest",
- size: "~11 MB",
- date: "",
- isInstaller: true
- });
- setUsingFallback(true);
- setLoading(false);
- });
- }, []);
-
- const getFileName = () => {
- if (!release) return "PingDiff";
- const version = release.version.replace('v', '');
- return release.isInstaller ? `PingDiff-Setup-${version}.exe` : `PingDiff-${release.version}.exe`;
- };
-
- return (
-
-
- Skip to main content
-
-
-
- {/* Main Content */}
-
-
-
Download PingDiff
-
- Get the desktop app to test your connection to game servers
-
-
-
- {/* Fallback Notice */}
- {usingFallback && (
-
-
-
- Couldn't fetch latest version info. Showing fallback download link.
- Check GitHub Releases for the latest version.
-
-
- )}
-
- {/* Download Card */}
-
-
- {/* Features */}
-
-
-
-
-
-
Safe & Open Source
-
- 100% open source. No malware, no tracking, no ads. Check the code yourself on GitHub or GitLab.
-
-
-
-
-
-
-
-
Your Privacy, Your Choice
-
- Toggle result sharing on or off. Your settings are saved locally and persist across updates.
-
-
-
-
-
-
-
-
Clean Install & Updates
-
- Proper Windows installer. Updates automatically clean up old versions while preserving your data.
-
-
-
-
- {/* System Requirements */}
-
-
System Requirements
-
-
-
- Windows 10 or Windows 11
-
-
-
- Internet connection
-
-
-
- 50MB free disk space
-
-
-
- Admin rights for installation
-
-
-
-
- {/* Data Storage Info */}
-
-
-
- Where is my data stored?
-
-
-
Program files: C:\Program Files\PingDiff
-
Settings & logs: %APPDATA%\PingDiff
-
Your settings and logs are preserved when you update to a new version.
-
-
-
- {/* Other Platforms */}
-
-
Other Platforms
-
- Mac and Linux versions coming soon. Want to be notified?
-
-
-
- macOS - Coming Soon
-
-
- Linux - Coming Soon
-
-
-
-
- {/* Instructions */}
-
-
How to Use
-
-
-
- 1
-
-
Download & run the installer
-
-
-
- 2
-
-
Launch PingDiff from Start Menu
-
-
-
- 3
-
-
Select your region
-
-
-
- 4
-
-
Click Test and see results
-
-
-
-
- {/* All Releases Links */}
-
-
-
-
-
- );
+ return ;
}