Skip to content
Merged
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
14 changes: 14 additions & 0 deletions IMPROVEMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,17 @@ and work offline. 68 tests across 10 test classes covering:
**Files changed:** `desktop/tests/__init__.py`, `desktop/tests/test_ping_tester.py`,
`desktop/tests/test_cli.py`, `desktop/pytest.ini`
**Lines:** +370 / -0

## 2026-03-18 — UI/UX: Skeleton loading screen for dashboard

The dashboard previously showed a centered spinner while fetching results from
/api/results, giving no visual indication of page structure and causing a jarring
layout shift when content arrived.

Replaced the spinner with a DashboardSkeleton component that mirrors the real
dashboard layout — four stat cards, two chart panels, and a six-row results table
— all with the existing .skeleton shimmer animation. No new dependencies. Screen
reader support via aria-busy="true" and aria-label on the skeleton root.

**Files changed:** `web/src/components/DashboardSkeleton.tsx` (new), `web/src/app/dashboard/page.tsx`
**Lines:** +123 / -4
6 changes: 2 additions & 4 deletions web/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "lucide-react";
import { Navbar } from "@/components/Navbar";
import { Footer } from "@/components/Footer";
import { DashboardSkeleton } from "@/components/DashboardSkeleton";
import {
LineChart,
Line,
Expand Down Expand Up @@ -181,10 +182,7 @@ export default function DashboardPage() {
</div>

{loading ? (
<div className="text-center py-20">
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-zinc-400">Loading results...</p>
</div>
<DashboardSkeleton />
) : error ? (
<div className="text-center py-20">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
Expand Down
121 changes: 121 additions & 0 deletions web/src/components/DashboardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* DashboardSkeleton — shimmer placeholders that mirror the real dashboard layout.
*
* Renders four stat cards, two chart panels, and a results table skeleton so the
* page feels instantly populated while data is in-flight. Uses a CSS animation
* defined in globals.css (shimmer keyframes) to avoid a runtime dependency.
*/

function SkeletonBlock({ className = "" }: { className?: string }) {
return (
<div
className={`skeleton ${className}`}
aria-hidden="true"
/>
);
}

function StatCardSkeleton() {
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
{/* Icon + label row */}
<div className="flex items-center gap-3 mb-3">
<SkeletonBlock className="w-5 h-5 rounded-full" />
<SkeletonBlock className="h-3 w-24" />
</div>
{/* Value */}
<SkeletonBlock className="h-9 w-20 mb-2" />
{/* Sub-label */}
<SkeletonBlock className="h-3 w-16" />
</div>
);
}

function ChartPanelSkeleton() {
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
{/* Title */}
<SkeletonBlock className="h-5 w-36 mb-5" />
{/* Chart area — fake bar/line columns */}
<div className="h-64 flex items-end gap-2 px-2">
{[45, 70, 55, 85, 40, 65, 75, 50, 90, 60].map((h, i) => (
<div
key={i}
className="skeleton flex-1 rounded-t"
style={{ height: `${h}%` }}
aria-hidden="true"
/>
))}
</div>
</div>
);
}

function TableRowSkeleton() {
return (
<tr className="border-t border-zinc-800">
<td className="py-4">
<SkeletonBlock className="h-4 w-32 mb-1.5" />
<SkeletonBlock className="h-3 w-16" />
</td>
<td className="py-4">
<SkeletonBlock className="h-4 w-14" />
</td>
<td className="py-4">
<SkeletonBlock className="h-4 w-12" />
</td>
<td className="py-4">
<SkeletonBlock className="h-4 w-10" />
</td>
<td className="py-4">
<SkeletonBlock className="h-4 w-24" />
</td>
<td className="py-4">
<SkeletonBlock className="h-4 w-20" />
</td>
</tr>
);
}

export function DashboardSkeleton() {
return (
<div aria-busy="true" aria-label="Loading dashboard data">
{/* Stat Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{[0, 1, 2, 3].map((i) => (
<StatCardSkeleton key={i} />
))}
</div>

{/* Charts */}
<div className="grid md:grid-cols-2 gap-6 mb-8">
<ChartPanelSkeleton />
<ChartPanelSkeleton />
</div>

{/* Recent Results Table */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
{/* Table title */}
<SkeletonBlock className="h-5 w-32 mb-5" />
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left">
{["Server", "Ping", "Jitter", "Loss", "ISP", "Time"].map((col) => (
<th key={col} className="pb-4">
<SkeletonBlock className="h-3 w-14" />
</th>
))}
</tr>
</thead>
<tbody>
{[0, 1, 2, 3, 4, 5].map((i) => (
<TableRowSkeleton key={i} />
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
Loading