Files
knur-app/app/running/[id]/page.tsx
Dominik Klarkowski ee178feff0 init
2026-06-16 11:51:10 +02:00

124 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 12, rows 13 — streamed in after page skeleton */}
<Suspense fallback={<MapSkeleton />}>
<RouteMapFetcher activity={activity} />
</Suspense>
{/* Col 3, rows 13: 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>
);
}