Implement skeleton loading states for dashboard and routes (P3.8)

Add skeleton loading components per specs/dashboard.md requirements:
- DecisionCardSkeleton: Shimmer placeholder for status and reason
- DataPanelSkeleton: Skeleton rows for 5 metrics
- NutritionPanelSkeleton: Skeleton for nutrition guidance
- MiniCalendarSkeleton: Placeholder grid with navigation and legend
- OverrideTogglesSkeleton: 4 toggle placeholders
- CycleInfoSkeleton: Cycle day and phase placeholders
- DashboardSkeleton: Combined skeleton for route-level loading

Add Next.js loading.tsx files for instant loading states:
- src/app/loading.tsx (Dashboard)
- src/app/calendar/loading.tsx
- src/app/history/loading.tsx
- src/app/plan/loading.tsx
- src/app/settings/loading.tsx

Update dashboard page to use DashboardSkeleton instead of "Loading..." text.

Fix flaky garmin test with wider date tolerance for timezone variations.

29 new tests in skeletons.test.tsx (749 total tests passing).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 09:32:09 +00:00
parent 714194f2d3
commit 9c5b8466f6
11 changed files with 708 additions and 13 deletions

View File

@@ -0,0 +1,53 @@
// ABOUTME: Route-level loading state for the calendar page.
// ABOUTME: Shows skeleton placeholders during page navigation.
export default function Loading() {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-black">
<header className="border-b bg-white dark:bg-zinc-900 px-6 py-4">
<div className="container mx-auto">
<h1 className="text-xl font-bold">Calendar</h1>
</div>
</header>
<main className="container mx-auto p-6">
<div className="animate-pulse space-y-6">
{/* Navigation skeleton */}
<div className="flex items-center justify-between">
<div className="w-8 h-8 bg-gray-200 rounded" />
<div className="flex items-center gap-2">
<div className="h-6 w-32 bg-gray-200 rounded" />
<div className="h-8 w-16 bg-gray-200 rounded" />
</div>
<div className="w-8 h-8 bg-gray-200 rounded" />
</div>
{/* Day headers */}
<div className="grid grid-cols-7 gap-2">
{[1, 2, 3, 4, 5, 6, 7].map((i) => (
<div key={i} className="h-8 bg-gray-200 rounded text-center" />
))}
</div>
{/* Calendar grid - 6 rows */}
<div className="grid grid-cols-7 gap-2">
{Array.from({ length: 42 }).map((_, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton placeholders never reorder
key={`skeleton-day-${i}`}
className="h-20 bg-gray-200 rounded"
/>
))}
</div>
{/* ICS Subscription section */}
<div className="rounded-lg border p-4 space-y-3">
<div className="h-5 w-48 bg-gray-200 rounded" />
<div className="h-10 bg-gray-200 rounded" />
<div className="h-4 w-64 bg-gray-200 rounded" />
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,50 @@
// ABOUTME: Route-level loading state for the history page.
// ABOUTME: Shows skeleton placeholders during page navigation.
export default function Loading() {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-black">
<header className="border-b bg-white dark:bg-zinc-900 px-6 py-4">
<div className="container mx-auto">
<h1 className="text-xl font-bold">History</h1>
</div>
</header>
<main className="container mx-auto p-6">
<div className="animate-pulse space-y-4">
{/* Date filter skeleton */}
<div className="flex gap-4 items-center">
<div className="h-10 w-40 bg-gray-200 rounded" />
<div className="h-10 w-40 bg-gray-200 rounded" />
<div className="h-10 w-24 bg-gray-200 rounded" />
</div>
{/* Table skeleton */}
<div className="rounded-lg border overflow-hidden">
{/* Header */}
<div className="grid grid-cols-5 gap-4 p-4 bg-gray-100">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-4 bg-gray-300 rounded" />
))}
</div>
{/* Rows */}
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((row) => (
<div key={row} className="grid grid-cols-5 gap-4 p-4 border-t">
{[1, 2, 3, 4, 5].map((col) => (
<div key={col} className="h-4 bg-gray-200 rounded" />
))}
</div>
))}
</div>
{/* Pagination skeleton */}
<div className="flex justify-center gap-2">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-8 w-8 bg-gray-200 rounded" />
))}
</div>
</div>
</main>
</div>
);
}

20
src/app/loading.tsx Normal file
View File

@@ -0,0 +1,20 @@
// ABOUTME: Route-level loading state for the dashboard.
// ABOUTME: Shows skeleton placeholders during page navigation.
import { DashboardSkeleton } from "@/components/dashboard/skeletons";
export default function Loading() {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-black">
<header className="border-b bg-white dark:bg-zinc-900 px-6 py-4">
<div className="container mx-auto flex justify-between items-center">
<h1 className="text-xl font-bold">PhaseFlow</h1>
<span className="text-sm text-zinc-400">Settings</span>
</div>
</header>
<main className="container mx-auto p-6">
<DashboardSkeleton />
</main>
</div>
);
}

View File

@@ -94,7 +94,10 @@ describe("Dashboard", () => {
render(<Dashboard />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Check for skeleton components which have aria-label "Loading ..."
expect(
screen.getByRole("region", { name: /loading decision/i }),
).toBeInTheDocument();
});
});

View File

@@ -9,6 +9,7 @@ import { DecisionCard } from "@/components/dashboard/decision-card";
import { MiniCalendar } from "@/components/dashboard/mini-calendar";
import { NutritionPanel } from "@/components/dashboard/nutrition-panel";
import { OverrideToggles } from "@/components/dashboard/override-toggles";
import { DashboardSkeleton } from "@/components/dashboard/skeletons";
import type {
CyclePhase,
Decision,
@@ -152,11 +153,7 @@ export default function Dashboard() {
</header>
<main className="container mx-auto p-6">
{loading && (
<div className="text-center py-12">
<p className="text-zinc-500">Loading...</p>
</div>
)}
{loading && <DashboardSkeleton />}
{error && (
<div role="alert" className="text-center py-12">

47
src/app/plan/loading.tsx Normal file
View File

@@ -0,0 +1,47 @@
// ABOUTME: Route-level loading state for the plan page.
// ABOUTME: Shows skeleton placeholders during page navigation.
export default function Loading() {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-black">
<header className="border-b bg-white dark:bg-zinc-900 px-6 py-4">
<div className="container mx-auto">
<h1 className="text-xl font-bold">Training Plan</h1>
</div>
</header>
<main className="container mx-auto p-6">
<div className="animate-pulse space-y-6">
{/* Current phase status */}
<div className="rounded-lg border p-6 space-y-3">
<div className="h-6 w-40 bg-gray-200 rounded" />
<div className="h-4 w-64 bg-gray-200 rounded" />
<div className="h-4 w-48 bg-gray-200 rounded" />
</div>
{/* Phase cards grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="rounded-lg border p-4 space-y-3">
<div className="h-5 w-32 bg-gray-200 rounded" />
<div className="h-4 w-full bg-gray-200 rounded" />
<div className="h-4 w-3/4 bg-gray-200 rounded" />
<div className="h-4 w-1/2 bg-gray-200 rounded" />
</div>
))}
</div>
{/* Exercise reference section */}
<div className="rounded-lg border p-6 space-y-4">
<div className="h-6 w-48 bg-gray-200 rounded" />
<div className="grid gap-3 md:grid-cols-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-4 bg-gray-200 rounded" />
))}
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,35 @@
// ABOUTME: Route-level loading state for the settings page.
// ABOUTME: Shows skeleton placeholders during page navigation.
export default function Loading() {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-black">
<header className="border-b bg-white dark:bg-zinc-900 px-6 py-4">
<div className="container mx-auto">
<h1 className="text-xl font-bold">Settings</h1>
</div>
</header>
<main className="container mx-auto p-6 max-w-2xl">
<div className="animate-pulse space-y-6">
{/* Form fields */}
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<div className="h-4 w-32 bg-gray-200 rounded" />
<div className="h-10 bg-gray-200 rounded" />
</div>
))}
{/* Submit button */}
<div className="h-10 w-32 bg-gray-200 rounded" />
{/* Garmin link section */}
<div className="border-t pt-6 space-y-2">
<div className="h-5 w-40 bg-gray-200 rounded" />
<div className="h-4 w-64 bg-gray-200 rounded" />
</div>
</div>
</main>
</div>
);
}