124 lines
5.5 KiB
TypeScript
124 lines
5.5 KiB
TypeScript
import { Suspense } from "react";
|
||
import { notFound } from "next/navigation";
|
||
import { AiAnalysisCard } from "@/components/ai-analysis-card";
|
||
import { RouteMapSection } from "@/components/route-map-section";
|
||
import { StatCard } from "@/components/stat-card";
|
||
import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format";
|
||
import { fetchActivityRoutePoints, getAuthorizedClient } from "@/lib/garmin/client";
|
||
import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
|
||
import { getRunningActivity, setRunningActivityRoutePoints, type RunningActivity } from "@/lib/models/running";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
async function RouteMapFetcher({ activity }: { activity: RunningActivity }) {
|
||
let routePoints = activity.routePoints;
|
||
|
||
if (!routePoints && activity.hasRoute) {
|
||
try {
|
||
const client = await getAuthorizedClient();
|
||
const points = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||
if (points) {
|
||
await setRunningActivityRoutePoints(activity.garminActivityId, points);
|
||
routePoints = points;
|
||
}
|
||
} catch {
|
||
// GPS fetch failed silently
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="col-span-2 row-span-3 min-h-[240px] overflow-hidden rounded-lg border border-muted/40">
|
||
{routePoints && routePoints.length > 0 ? (
|
||
<RouteMapSection points={routePoints} />
|
||
) : (
|
||
<div className="flex h-full min-h-[240px] items-center justify-center bg-surface">
|
||
<span className="text-sm text-fg/30">Brak danych GPS</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MapSkeleton() {
|
||
return (
|
||
<div className="col-span-2 row-span-3 min-h-[240px] animate-pulse overflow-hidden rounded-lg border border-muted/40 bg-surface" />
|
||
);
|
||
}
|
||
|
||
export default async function RunningActivityPage({
|
||
params,
|
||
}: {
|
||
params: Promise<{ id: string }>;
|
||
}) {
|
||
const { id } = await params;
|
||
const activity = await getRunningActivity(id);
|
||
|
||
if (!activity) {
|
||
notFound();
|
||
}
|
||
|
||
const analysis = await getLatestAnalysisForTarget("running", activity._id);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-6">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-fg">{activity.name}</h1>
|
||
<p className="mt-1 text-sm text-fg/60">{formatDate(activity.startTime)}</p>
|
||
</div>
|
||
|
||
<section className="grid grid-cols-3 gap-4">
|
||
{/* Map: cols 1–2, rows 1–3 — streamed in after page skeleton */}
|
||
<Suspense fallback={<MapSkeleton />}>
|
||
<RouteMapFetcher activity={activity} />
|
||
</Suspense>
|
||
|
||
{/* Col 3, rows 1–3: key pace stats */}
|
||
<StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} />
|
||
<StatCard highlight label="Czas" value={formatDuration(activity.durationSec)} />
|
||
<StatCard highlight label="Tempo" value={formatPace(activity.avgPaceSecPerKm)} />
|
||
|
||
{/* Row 4: HR, calories, cadence — always shown */}
|
||
<StatCard highlight label="Średnie HR" value={activity.avgHr ? `${Math.round(activity.avgHr)} bpm` : "—"} />
|
||
<StatCard highlight label="Kalorie" value={activity.calories ? `${Math.round(activity.calories)} kcal` : "—"} />
|
||
<StatCard highlight label="Kadencja" value={activity.avgCadence ? `${Math.round(activity.avgCadence)} kr/min` : "—"} />
|
||
|
||
{/* Row 5+: optional advanced stats, auto-flow */}
|
||
{activity.maxHr ? <StatCard label="Maks. HR" value={`${Math.round(activity.maxHr)} bpm`} /> : null}
|
||
{activity.elevationGainM ? <StatCard label="Podejście" value={`${Math.round(activity.elevationGainM)} m`} /> : null}
|
||
{activity.vo2Max ? <StatCard label="VO2max" value={`${Math.round(activity.vo2Max)}`} /> : null}
|
||
{activity.avgGroundContactTimeMs ? (
|
||
<StatCard label="Czas kontaktu z podłożem" value={`${Math.round(activity.avgGroundContactTimeMs)} ms`} />
|
||
) : null}
|
||
{activity.avgVerticalOscillationCm ? (
|
||
<StatCard label="Oscylacja wertykalna" value={`${activity.avgVerticalOscillationCm.toFixed(1)} cm`} />
|
||
) : null}
|
||
{activity.avgVerticalRatioPct ? (
|
||
<StatCard label="Wskaźnik wertykalny" value={`${activity.avgVerticalRatioPct.toFixed(1)}%`} />
|
||
) : null}
|
||
{activity.avgStrideLengthCm ? (
|
||
<StatCard label="Długość kroku" value={`${activity.avgStrideLengthCm.toFixed(0)} cm`} />
|
||
) : null}
|
||
{activity.avgGroundContactBalanceLeftPct ? (
|
||
<StatCard
|
||
label="Balans kontaktu (L/P)"
|
||
value={`${activity.avgGroundContactBalanceLeftPct.toFixed(1)}% / ${(100 - activity.avgGroundContactBalanceLeftPct).toFixed(1)}%`}
|
||
/>
|
||
) : null}
|
||
{activity.avgPowerW ? <StatCard label="Moc średnia" value={`${Math.round(activity.avgPowerW)} W`} /> : null}
|
||
{activity.maxPowerW ? <StatCard label="Moc maks." value={`${Math.round(activity.maxPowerW)} W`} /> : null}
|
||
{activity.avgRespirationRate ? (
|
||
<StatCard label="Częstość oddechów" value={`${activity.avgRespirationRate.toFixed(1)} /min`} />
|
||
) : null}
|
||
{activity.aerobicTrainingEffect ? (
|
||
<StatCard label="Efekt aerobowy" value={activity.aerobicTrainingEffect.toFixed(1)} />
|
||
) : null}
|
||
{activity.anaerobicTrainingEffect ? (
|
||
<StatCard label="Efekt anaerobowy" value={activity.anaerobicTrainingEffect.toFixed(1)} />
|
||
) : null}
|
||
</section>
|
||
|
||
<AiAnalysisCard targetType="running" targetId={activity._id.toString()} analysis={analysis ? serializeAnalysis(analysis) : null} />
|
||
</div>
|
||
);
|
||
}
|