Skip to content

Commit b0f5b66

Browse files
authored
feat: add date range filter and CSV export to dashboard (#6)
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. Co-authored-by: bokiko <bokiko@users.noreply.github.com>
1 parent aa21698 commit b0f5b66

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";
@@ -44,11 +45,75 @@ interface TestResult {
4445
};
4546
}
4647

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

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

80157
const avgPing =
81158
filteredResults.length > 0
@@ -101,7 +178,7 @@ export default function DashboardPage() {
101178
).toFixed(1)
102179
: "0";
103180

104-
// Get unique regions
181+
// Get unique regions (from all results, not filtered)
105182
const regions = [
106183
...new Set(results.map((r) => r.game_servers?.region).filter(Boolean)),
107184
];
@@ -153,22 +230,34 @@ export default function DashboardPage() {
153230

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

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

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

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

0 commit comments

Comments
 (0)