2026-06-16 09:43:48 +02:00
|
|
|
import { Suspense } from "react";
|
|
|
|
|
import { notFound } from "next/navigation";
|
|
|
|
|
import { AiAnalysisCard } from "@/components/ai-analysis-card";
|
2026-06-18 09:43:25 +02:00
|
|
|
import { ElevationChart } from "@/components/elevation-chart";
|
2026-06-16 09:43:48 +02:00
|
|
|
import { RouteMapSection } from "@/components/route-map-section";
|
2026-06-18 09:43:25 +02:00
|
|
|
import { GcbChart } from "@/components/gcb-chart";
|
|
|
|
|
import { RunMetricChart } from "@/components/run-metric-chart";
|
2026-06-16 09:43:48 +02:00
|
|
|
import { StatCard } from "@/components/stat-card";
|
|
|
|
|
import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format";
|
2026-06-18 09:43:25 +02:00
|
|
|
import {
|
|
|
|
|
fetchActivityRoutePoints,
|
|
|
|
|
fetchActivityRunMetrics,
|
|
|
|
|
getAuthorizedClient,
|
|
|
|
|
} from "@/lib/garmin/client";
|
2026-06-16 11:51:10 +02:00
|
|
|
import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
|
2026-06-18 09:43:25 +02:00
|
|
|
import {
|
|
|
|
|
getRunningActivity,
|
|
|
|
|
setRunningActivityMetrics,
|
|
|
|
|
setRunningActivityRoutePoints,
|
|
|
|
|
type RunMetrics,
|
|
|
|
|
type RunningActivity,
|
|
|
|
|
} from "@/lib/models/running";
|
2026-06-18 11:02:31 +02:00
|
|
|
import { getCurrentUserId } from "@/lib/session";
|
2026-06-16 09:43:48 +02:00
|
|
|
|
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
|
|
2026-06-18 09:43:25 +02:00
|
|
|
function mayHaveRoute(activity: RunningActivity): boolean {
|
|
|
|
|
return Boolean(activity.hasRoute) || Boolean(activity.routePoints?.length);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 11:02:31 +02:00
|
|
|
async function RouteMapFetcher({
|
|
|
|
|
activity,
|
|
|
|
|
userId,
|
|
|
|
|
}: {
|
|
|
|
|
activity: RunningActivity;
|
|
|
|
|
userId: string;
|
|
|
|
|
}) {
|
2026-06-16 09:43:48 +02:00
|
|
|
let routePoints = activity.routePoints;
|
|
|
|
|
|
2026-06-18 09:43:25 +02:00
|
|
|
if ((!routePoints || !activity.elevationProfile) && mayHaveRoute(activity)) {
|
2026-06-16 09:43:48 +02:00
|
|
|
try {
|
2026-06-18 11:02:31 +02:00
|
|
|
const client = await getAuthorizedClient(userId);
|
2026-06-18 09:43:25 +02:00
|
|
|
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
|
|
|
|
if (result) {
|
2026-06-18 11:02:31 +02:00
|
|
|
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
|
2026-06-18 09:43:25 +02:00
|
|
|
routePoints = result.points;
|
2026-06-16 09:43:48 +02:00
|
|
|
}
|
|
|
|
|
} 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 09:43:25 +02:00
|
|
|
function hasValidElevation(profile: number[] | undefined): boolean {
|
|
|
|
|
return Array.isArray(profile) && profile.some((v) => v > 0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 11:02:31 +02:00
|
|
|
async function ElevationFetcher({
|
|
|
|
|
activity,
|
|
|
|
|
userId,
|
|
|
|
|
}: {
|
|
|
|
|
activity: RunningActivity;
|
|
|
|
|
userId: string;
|
|
|
|
|
}) {
|
2026-06-18 09:43:25 +02:00
|
|
|
let elevationProfile = activity.elevationProfile;
|
|
|
|
|
|
|
|
|
|
if (!hasValidElevation(elevationProfile) && mayHaveRoute(activity)) {
|
|
|
|
|
try {
|
2026-06-18 11:02:31 +02:00
|
|
|
const client = await getAuthorizedClient(userId);
|
2026-06-18 09:43:25 +02:00
|
|
|
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
|
|
|
|
if (result) {
|
2026-06-18 11:02:31 +02:00
|
|
|
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
|
2026-06-18 09:43:25 +02:00
|
|
|
elevationProfile = result.elevationProfile;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// silent
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!elevationProfile || elevationProfile.length < 2) return null;
|
|
|
|
|
|
2026-06-18 11:02:31 +02:00
|
|
|
const elevData = elevationProfile
|
2026-06-18 09:43:25 +02:00
|
|
|
.map((altM, i) => ({
|
|
|
|
|
distanceKm: Math.round((i / elevationProfile!.length) * activity.distanceM / 10) / 100,
|
|
|
|
|
altM,
|
|
|
|
|
}))
|
|
|
|
|
.filter((p) => p.altM > 0);
|
|
|
|
|
|
2026-06-18 11:02:31 +02:00
|
|
|
if (elevData.length < 2) return null;
|
|
|
|
|
|
|
|
|
|
// Merge pace by fractional position (both arrays span the same run, different sample counts)
|
|
|
|
|
const paceSrc = activity.runMetrics?.paceSec;
|
|
|
|
|
const data = elevData.map((ep, i) => {
|
|
|
|
|
if (!paceSrc || paceSrc.length === 0) return ep;
|
|
|
|
|
const pi = Math.min(Math.round((i / elevData.length) * paceSrc.length), paceSrc.length - 1);
|
|
|
|
|
const v = paceSrc[pi];
|
|
|
|
|
return { ...ep, paceSec: v > 0 && v < 1800 ? v : undefined };
|
|
|
|
|
});
|
2026-06-18 09:43:25 +02:00
|
|
|
|
|
|
|
|
return <ElevationChart data={data} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toChartData(
|
|
|
|
|
values: number[] | undefined,
|
|
|
|
|
distances: number[]
|
|
|
|
|
): { distanceKm: number; value: number }[] {
|
|
|
|
|
if (!values) return [];
|
|
|
|
|
return distances
|
|
|
|
|
.map((distanceKm, i) => ({ distanceKm, value: values[i] ?? 0 }))
|
|
|
|
|
.filter((p) => p.value > 0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 11:02:31 +02:00
|
|
|
async function RunMetricsFetcher({
|
|
|
|
|
activity,
|
|
|
|
|
userId,
|
|
|
|
|
}: {
|
|
|
|
|
activity: RunningActivity;
|
|
|
|
|
userId: string;
|
|
|
|
|
}) {
|
2026-06-18 09:43:25 +02:00
|
|
|
let metrics: RunMetrics | undefined = activity.runMetrics;
|
|
|
|
|
|
|
|
|
|
const missingCadence = activity.avgCadence && !metrics?.cadenceSpm;
|
|
|
|
|
const missingGcb = activity.avgGroundContactBalanceLeftPct && !metrics?.gcbLeftPct;
|
2026-06-18 11:02:31 +02:00
|
|
|
// Re-fetch if paceSec missing or was computed from integer-rounded speeds (< 15 unique values = rounding artifact)
|
|
|
|
|
const validPace = metrics?.paceSec?.filter((v) => v > 0) ?? [];
|
|
|
|
|
const missingPace = validPace.length === 0 || new Set(validPace).size < 15;
|
2026-06-18 09:43:25 +02:00
|
|
|
|
2026-06-18 11:02:31 +02:00
|
|
|
if ((!metrics || missingCadence || missingGcb || missingPace) && mayHaveRoute(activity)) {
|
2026-06-18 09:43:25 +02:00
|
|
|
try {
|
2026-06-18 11:02:31 +02:00
|
|
|
const client = await getAuthorizedClient(userId);
|
2026-06-18 09:43:25 +02:00
|
|
|
const fetched = await fetchActivityRunMetrics(client, activity.garminActivityId);
|
|
|
|
|
if (fetched) {
|
2026-06-18 11:02:31 +02:00
|
|
|
await setRunningActivityMetrics(userId, activity.garminActivityId, fetched);
|
2026-06-18 09:43:25 +02:00
|
|
|
metrics = fetched;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// silent
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!metrics || metrics.distanceKm.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
const { hrBpm, gcbLeftPct } = metrics;
|
|
|
|
|
|
|
|
|
|
const maxDist = Math.max(...metrics.distanceKm);
|
|
|
|
|
const distanceKm =
|
|
|
|
|
maxDist > 0
|
|
|
|
|
? metrics.distanceKm
|
|
|
|
|
: Array.from({ length: metrics.distanceKm.length }, (_, i) =>
|
|
|
|
|
Math.round(((i / (metrics.distanceKm.length - 1)) * activity.distanceM) / 10) / 100
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const hrData = toChartData(hrBpm, distanceKm);
|
|
|
|
|
const gcbData = gcbLeftPct
|
|
|
|
|
? distanceKm
|
|
|
|
|
.map((d, i) => {
|
|
|
|
|
const left = gcbLeftPct[i] ?? 0;
|
|
|
|
|
return left > 0
|
|
|
|
|
? { distanceKm: d, left, right: Math.round((100 - left) * 10) / 10 }
|
|
|
|
|
: null;
|
|
|
|
|
})
|
|
|
|
|
.filter((p): p is NonNullable<typeof p> => p !== null)
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
if (!hrData.length && !gcbData.length) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
|
|
|
{hrData.length > 1 && (
|
2026-06-18 11:02:31 +02:00
|
|
|
<RunMetricChart data={hrData} label="Tętno" unit="bpm" color="var(--color-accent)" />
|
2026-06-18 09:43:25 +02:00
|
|
|
)}
|
|
|
|
|
{gcbData.length > 1 && <GcbChart data={gcbData} />}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 09:43:48 +02:00
|
|
|
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;
|
2026-06-18 11:02:31 +02:00
|
|
|
const userId = await getCurrentUserId();
|
|
|
|
|
const activity = await getRunningActivity(userId, id);
|
2026-06-16 09:43:48 +02:00
|
|
|
|
|
|
|
|
if (!activity) {
|
|
|
|
|
notFound();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 11:02:31 +02:00
|
|
|
const analysis = await getLatestAnalysisForTarget(userId, "running", activity._id);
|
2026-06-16 09:43:48 +02:00
|
|
|
|
|
|
|
|
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">
|
|
|
|
|
<Suspense fallback={<MapSkeleton />}>
|
2026-06-18 11:02:31 +02:00
|
|
|
<RouteMapFetcher activity={activity} userId={userId} />
|
2026-06-16 09:43:48 +02:00
|
|
|
</Suspense>
|
|
|
|
|
|
|
|
|
|
<StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} />
|
|
|
|
|
<StatCard highlight label="Czas" value={formatDuration(activity.durationSec)} />
|
|
|
|
|
<StatCard highlight label="Tempo" value={formatPace(activity.avgPaceSecPerKm)} />
|
|
|
|
|
|
|
|
|
|
<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` : "—"} />
|
|
|
|
|
|
|
|
|
|
{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>
|
|
|
|
|
|
2026-06-18 09:43:25 +02:00
|
|
|
<Suspense fallback={null}>
|
2026-06-18 11:02:31 +02:00
|
|
|
<ElevationFetcher activity={activity} userId={userId} />
|
2026-06-18 09:43:25 +02:00
|
|
|
</Suspense>
|
|
|
|
|
|
|
|
|
|
<Suspense fallback={null}>
|
2026-06-18 11:02:31 +02:00
|
|
|
<RunMetricsFetcher activity={activity} userId={userId} />
|
2026-06-18 09:43:25 +02:00
|
|
|
</Suspense>
|
|
|
|
|
|
2026-06-18 11:02:31 +02:00
|
|
|
<AiAnalysisCard
|
|
|
|
|
targetType="running"
|
|
|
|
|
targetId={activity._id.toString()}
|
|
|
|
|
analysis={analysis ? serializeAnalysis(analysis) : null}
|
|
|
|
|
/>
|
2026-06-16 09:43:48 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|