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>
224 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|