Files
phaseflow/src/app/page.tsx
Petru Paler 9c5b8466f6 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>
2026-01-11 09:32:09 +00:00

224 lines
6.6 KiB
TypeScript

// ABOUTME: Main dashboard page for PhaseFlow.
// ABOUTME: Displays today's training decision, biometrics, and quick actions.
"use client";
import { useCallback, useEffect, useState } from "react";
import { DataPanel } from "@/components/dashboard/data-panel";
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,
NutritionGuidance,
OverrideType,
PhaseConfig,
} from "@/types";
interface TodayData {
decision: Decision;
cycleDay: number;
phase: CyclePhase;
phaseConfig: PhaseConfig;
daysUntilNextPhase: number;
cycleLength: number;
biometrics: {
hrvStatus: string;
bodyBatteryCurrent: number;
bodyBatteryYesterdayLow: number;
weekIntensityMinutes: number;
phaseLimit: number;
};
nutrition: NutritionGuidance;
}
interface UserData {
id: string;
email: string;
activeOverrides: OverrideType[];
lastPeriodDate: string | null;
cycleLength: number;
}
export default function Dashboard() {
const [todayData, setTodayData] = useState<TodayData | null>(null);
const [userData, setUserData] = useState<UserData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [calendarYear, setCalendarYear] = useState(new Date().getFullYear());
const [calendarMonth, setCalendarMonth] = useState(new Date().getMonth());
const handleCalendarMonthChange = useCallback(
(year: number, month: number) => {
setCalendarYear(year);
setCalendarMonth(month);
},
[],
);
const fetchTodayData = useCallback(async () => {
const response = await fetch("/api/today");
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to fetch today data");
}
return data as TodayData;
}, []);
const fetchUserData = useCallback(async () => {
const response = await fetch("/api/user");
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to fetch user data");
}
return data as UserData;
}, []);
useEffect(() => {
async function loadData() {
try {
setLoading(true);
setError(null);
const [today, user] = await Promise.all([
fetchTodayData(),
fetchUserData(),
]);
setTodayData(today);
setUserData(user);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setLoading(false);
}
}
loadData();
}, [fetchTodayData, fetchUserData]);
const handleOverrideToggle = async (override: OverrideType) => {
if (!userData) return;
const isActive = userData.activeOverrides.includes(override);
const method = isActive ? "DELETE" : "POST";
try {
const response = await fetch("/api/overrides", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ override }),
});
if (!response.ok) {
throw new Error("Failed to update override");
}
const result = await response.json();
// Update local state with new overrides
setUserData((prev) =>
prev ? { ...prev, activeOverrides: result.activeOverrides } : null,
);
// Refetch today data to get updated decision
const newTodayData = await fetchTodayData();
setTodayData(newTodayData);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to toggle override",
);
}
};
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>
<a
href="/settings"
className="text-sm text-zinc-600 hover:text-zinc-900"
>
Settings
</a>
</div>
</header>
<main className="container mx-auto p-6">
{loading && <DashboardSkeleton />}
{error && (
<div role="alert" className="text-center py-12">
<p className="text-red-500">Error: {error}</p>
{error.includes("lastPeriodDate") && (
<p className="text-sm text-zinc-400 mt-2">
Please log your period start date to get started.
</p>
)}
</div>
)}
{!loading && !error && todayData && userData && (
<div className="space-y-6">
{/* Cycle Info */}
<div className="text-center">
<p className="text-lg font-medium">
Day {todayData.cycleDay} · {todayData.phase}
</p>
<p className="text-sm text-zinc-500">
{todayData.daysUntilNextPhase} days until next phase
</p>
</div>
{/* Decision Card */}
<DecisionCard decision={todayData.decision} />
{/* Data and Nutrition Grid */}
<div className="grid gap-4 md:grid-cols-2">
<DataPanel
bodyBatteryCurrent={todayData.biometrics.bodyBatteryCurrent}
bodyBatteryYesterdayLow={
todayData.biometrics.bodyBatteryYesterdayLow
}
hrvStatus={todayData.biometrics.hrvStatus}
weekIntensity={todayData.biometrics.weekIntensityMinutes}
phaseLimit={todayData.biometrics.phaseLimit}
remainingMinutes={
todayData.biometrics.phaseLimit -
todayData.biometrics.weekIntensityMinutes
}
/>
<NutritionPanel nutrition={todayData.nutrition} />
</div>
{/* Override Toggles */}
<OverrideToggles
activeOverrides={userData.activeOverrides}
onToggle={handleOverrideToggle}
/>
{/* Mini Calendar */}
{userData.lastPeriodDate && (
<MiniCalendar
lastPeriodDate={new Date(userData.lastPeriodDate)}
cycleLength={userData.cycleLength}
year={calendarYear}
month={calendarMonth}
onMonthChange={handleCalendarMonthChange}
/>
)}
</div>
)}
</main>
</div>
);
}