Skip to content

Commit c373159

Browse files
committed
feat: add date range filter and CSV export to dashboard
Two practical dashboard improvements in one cohesive change: 1. Date range filter (7 / 30 / 90 days / All time) - Defaults to Last 30 days to show recent data first - Applied before the existing region filter (composable) - Empty-state UI when filters produce no results, with a 'Clear Filters' button to reset both dropdowns at once 2. CSV export - 'Export CSV' button appears only when filtered results exist - Exports the current filtered view (date + region) so users get exactly what they see on screen - RFC 4180-compliant quoting (commas and quotes in fields escaped) - Filename includes today's date (pingdiff-results-YYYY-MM-DD.csv) - Columns: Date, Server, Region, Avg/Min/Max Ping, Jitter, Packet Loss, ISP, Country, City Also: moved the Refresh button into the filter bar (consistent toolbar), added a result count label to the Recent Tests table header, and improved the header layout to stack cleanly on mobile (flex-col → sm:flex-row). No new dependencies. No API changes. Pure frontend.
1 parent 6ef9e9f commit c373159

1 file changed

Lines changed: 145 additions & 13 deletions

File tree

web/src/app/dashboard/page.tsx

Lines changed: 145 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useEffect } from "react";
3+
import { useState, useEffect, useCallback } from "react";
44
import Link from "next/link";
55
import {
66
Download,
@@ -11,6 +11,7 @@ import {
1111
AlertTriangle,
1212
RefreshCw,
1313
AlertCircle,
14+
FileDown,
1415
} from "lucide-react";
1516
import { Navbar } from "@/components/Navbar";
1617
import { Footer } from "@/components/Footer";
@@ -43,11 +44,75 @@ interface TestResult {
4344
};
4445
}
4546

47+
type DateRange = "7" | "30" | "90" | "all";
48+
49+
const DATE_RANGE_OPTIONS: { value: DateRange; label: string }[] = [
50+
{ value: "7", label: "Last 7 days" },
51+
{ value: "30", label: "Last 30 days" },
52+
{ value: "90", label: "Last 90 days" },
53+
{ value: "all", label: "All time" },
54+
];
55+
56+
function exportToCSV(results: TestResult[]) {
57+
const headers = [
58+
"Date",
59+
"Server",
60+
"Region",
61+
"Avg Ping (ms)",
62+
"Min Ping (ms)",
63+
"Max Ping (ms)",
64+
"Jitter (ms)",
65+
"Packet Loss (%)",
66+
"ISP",
67+
"Country",
68+
"City",
69+
];
70+
71+
const rows = results.map((r) => [
72+
new Date(r.created_at).toISOString(),
73+
r.game_servers?.location ?? "Unknown",
74+
r.game_servers?.region ?? "",
75+
r.ping_avg,
76+
r.ping_min,
77+
r.ping_max,
78+
r.jitter?.toFixed(2) ?? "0",
79+
r.packet_loss,
80+
r.isp ?? "Unknown",
81+
r.country ?? "",
82+
r.city ?? "",
83+
]);
84+
85+
const csvContent = [headers, ...rows]
86+
.map((row) =>
87+
row
88+
.map((cell) => {
89+
const str = String(cell);
90+
// Wrap in quotes if contains comma, quote, or newline
91+
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
92+
return `"${str.replace(/"/g, '""')}"`;
93+
}
94+
return str;
95+
})
96+
.join(",")
97+
)
98+
.join("\n");
99+
100+
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
101+
const url = URL.createObjectURL(blob);
102+
const link = document.createElement("a");
103+
link.href = url;
104+
link.download = `pingdiff-results-${new Date().toISOString().slice(0, 10)}.csv`;
105+
link.click();
106+
URL.revokeObjectURL(url);
107+
}
108+
46109
export default function DashboardPage() {
47110
const [results, setResults] = useState<TestResult[]>([]);
48111
const [loading, setLoading] = useState(true);
49112
const [error, setError] = useState<string | null>(null);
50113
const [selectedRegion, setSelectedRegion] = useState<string>("all");
114+
const [dateRange, setDateRange] = useState<DateRange>("30");
115+
51116
useEffect(() => {
52117
fetchResults();
53118
}, []);
@@ -70,11 +135,23 @@ export default function DashboardPage() {
70135
}
71136
};
72137

73-
// Calculate stats
138+
// Apply date range filter
139+
const applyDateFilter = useCallback(
140+
(data: TestResult[]): TestResult[] => {
141+
if (dateRange === "all") return data;
142+
const cutoff = new Date();
143+
cutoff.setDate(cutoff.getDate() - parseInt(dateRange));
144+
return data.filter((r) => new Date(r.created_at) >= cutoff);
145+
},
146+
[dateRange]
147+
);
148+
149+
// Apply region filter on top of date filter
150+
const dateFiltered = applyDateFilter(results);
74151
const filteredResults =
75152
selectedRegion === "all"
76-
? results
77-
: results.filter((r) => r.game_servers?.region === selectedRegion);
153+
? dateFiltered
154+
: dateFiltered.filter((r) => r.game_servers?.region === selectedRegion);
78155

79156
const avgPing =
80157
filteredResults.length > 0
@@ -100,7 +177,7 @@ export default function DashboardPage() {
100177
).toFixed(1)
101178
: "0";
102179

103-
// Get unique regions
180+
// Get unique regions (from all results, not filtered)
104181
const regions = [
105182
...new Set(results.map((r) => r.game_servers?.region).filter(Boolean)),
106183
];
@@ -152,22 +229,34 @@ export default function DashboardPage() {
152229

153230
{/* Main Content */}
154231
<main className="max-w-6xl mx-auto px-4 py-8">
155-
<div className="flex justify-between items-center mb-8">
232+
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-8">
156233
<div>
157234
<h1 className="text-3xl font-bold">Dashboard</h1>
158235
<p className="text-zinc-400">Your connection test results</p>
159236
</div>
160237

161-
{/* Region Filter */}
162-
<div className="flex items-center gap-2">
163-
<label htmlFor="region-filter" className="text-zinc-400 text-sm sr-only md:not-sr-only">
164-
Filter by:
165-
</label>
238+
{/* Filters */}
239+
<div className="flex flex-wrap items-center gap-2">
240+
{/* Date range filter */}
241+
<select
242+
value={dateRange}
243+
onChange={(e) => setDateRange(e.target.value as DateRange)}
244+
className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus-ring"
245+
aria-label="Filter results by date range"
246+
>
247+
{DATE_RANGE_OPTIONS.map((opt) => (
248+
<option key={opt.value} value={opt.value}>
249+
{opt.label}
250+
</option>
251+
))}
252+
</select>
253+
254+
{/* Region filter */}
166255
<select
167256
id="region-filter"
168257
value={selectedRegion}
169258
onChange={(e) => setSelectedRegion(e.target.value)}
170-
className="bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-2 focus-ring"
259+
className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus-ring"
171260
aria-label="Filter results by region"
172261
>
173262
<option value="all">All Regions</option>
@@ -177,6 +266,30 @@ export default function DashboardPage() {
177266
</option>
178267
))}
179268
</select>
269+
270+
{/* Export CSV button */}
271+
{filteredResults.length > 0 && (
272+
<button
273+
onClick={() => exportToCSV(filteredResults)}
274+
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"
275+
aria-label={`Export ${filteredResults.length} results to CSV`}
276+
title="Export filtered results as CSV"
277+
>
278+
<FileDown className="w-4 h-4" />
279+
Export CSV
280+
</button>
281+
)}
282+
283+
{/* Refresh */}
284+
<button
285+
onClick={fetchResults}
286+
disabled={loading}
287+
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 disabled:opacity-50"
288+
aria-label="Refresh results"
289+
>
290+
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
291+
Refresh
292+
</button>
180293
</div>
181294
</div>
182295

@@ -213,6 +326,20 @@ export default function DashboardPage() {
213326
Download PingDiff
214327
</Link>
215328
</div>
329+
) : filteredResults.length === 0 ? (
330+
<div className="text-center py-20">
331+
<Clock className="w-16 h-16 text-zinc-600 mx-auto mb-4" />
332+
<h2 className="text-xl font-semibold mb-2">No Results in This Range</h2>
333+
<p className="text-zinc-400 mb-6">
334+
No tests found for the selected filters. Try a wider date range or different region.
335+
</p>
336+
<button
337+
onClick={() => { setDateRange("all"); setSelectedRegion("all"); }}
338+
className="inline-flex items-center gap-2 bg-zinc-700 hover:bg-zinc-600 px-6 py-3 rounded-lg font-medium transition"
339+
>
340+
Clear Filters
341+
</button>
342+
</div>
216343
) : (
217344
<>
218345
{/* Stats Cards */}
@@ -327,7 +454,12 @@ export default function DashboardPage() {
327454

328455
{/* Recent Results Table */}
329456
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
330-
<h3 className="text-lg font-semibold mb-4">Recent Tests</h3>
457+
<div className="flex items-center justify-between mb-4">
458+
<h3 className="text-lg font-semibold">Recent Tests</h3>
459+
<span className="text-sm text-zinc-500">
460+
Showing {Math.min(filteredResults.length, 10)} of {filteredResults.length}
461+
</span>
462+
</div>
331463
<div className="overflow-x-auto">
332464
<table className="w-full">
333465
<thead>

0 commit comments

Comments
 (0)