209 lines
8.1 KiB
TypeScript
209 lines
8.1 KiB
TypeScript
import { notFound } from "next/navigation";
|
|
import { AiAnalysisCard } from "@/components/ai-analysis-card";
|
|
import { ElevationChart } from "@/components/elevation-chart";
|
|
import { RouteMapSection } from "@/components/route-map-section";
|
|
import { GcbChart } from "@/components/gcb-chart";
|
|
import { RunMetricChart } from "@/components/run-metric-chart";
|
|
import { StatCard } from "@/components/stat-card";
|
|
import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format";
|
|
import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
|
|
import {
|
|
getRunningActivity,
|
|
type RunningActivity,
|
|
} from "@/lib/models/running";
|
|
import { getCurrentUserId } from "@/lib/session";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const CHART_SAMPLES = 200;
|
|
|
|
function interpolate(sorted: { dist: number; value: number }[], target: number): number | null {
|
|
if (sorted.length === 0) return null;
|
|
if (target <= sorted[0].dist) return sorted[0].value;
|
|
if (target >= sorted[sorted.length - 1].dist) return sorted[sorted.length - 1].value;
|
|
let lo = 0;
|
|
let hi = sorted.length - 1;
|
|
while (lo + 1 < hi) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (sorted[mid].dist <= target) lo = mid; else hi = mid;
|
|
}
|
|
const t = (target - sorted[lo].dist) / (sorted[hi].dist - sorted[lo].dist);
|
|
return sorted[lo].value + t * (sorted[hi].value - sorted[lo].value);
|
|
}
|
|
|
|
function buildGrid(maxDistKm: number): number[] {
|
|
return Array.from({ length: CHART_SAMPLES }, (_, i) =>
|
|
Math.round((i / (CHART_SAMPLES - 1)) * maxDistKm * 100) / 100
|
|
);
|
|
}
|
|
|
|
function RouteMap({ activity }: { activity: RunningActivity }) {
|
|
const routePoints = activity.routePoints;
|
|
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 — zsynchronizuj ponownie</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function buildChartData(activity: RunningActivity) {
|
|
const maxDistKm = Math.round(activity.distanceM / 10) / 100;
|
|
const grid = buildGrid(maxDistKm);
|
|
|
|
const elevProfile = activity.elevationProfile;
|
|
const metrics = activity.runMetrics;
|
|
|
|
const elevSorted = elevProfile
|
|
? elevProfile
|
|
.map((altM, i) => ({ dist: (i / elevProfile.length) * maxDistKm, value: altM }))
|
|
.filter((p) => p.value > 0)
|
|
: [];
|
|
|
|
const metricsDistKm = metrics
|
|
? (() => {
|
|
const raw = metrics.distanceKm;
|
|
const max = Math.max(...raw);
|
|
return max > 0
|
|
? raw
|
|
: raw.map((_, i) => Math.round(((i / (raw.length - 1)) * maxDistKm) * 100) / 100);
|
|
})()
|
|
: [];
|
|
|
|
const hrSorted = metrics?.hrBpm
|
|
? metricsDistKm
|
|
.map((d, i) => ({ dist: d, value: metrics.hrBpm![i] ?? 0 }))
|
|
.filter((p) => p.value > 0)
|
|
: [];
|
|
|
|
const paceSorted = metrics?.paceSec
|
|
? metricsDistKm
|
|
.map((d, i) => ({ dist: d, value: metrics.paceSec![i] ?? 0 }))
|
|
.filter((p) => p.value > 0 && p.value < 1800)
|
|
: [];
|
|
|
|
const gcbSorted = metrics?.gcbLeftPct
|
|
? metricsDistKm
|
|
.map((d, i) => ({ dist: d, value: metrics.gcbLeftPct![i] ?? 0 }))
|
|
.filter((p) => p.value > 0)
|
|
: [];
|
|
|
|
const hasElev = elevSorted.length >= 2;
|
|
const hasHr = hrSorted.length >= 2;
|
|
const hasGcb = gcbSorted.length >= 2;
|
|
|
|
const elevData = hasElev
|
|
? grid.map((distanceKm) => {
|
|
const altM = interpolate(elevSorted, distanceKm) ?? 0;
|
|
const paceSec = paceSorted.length >= 2 ? interpolate(paceSorted, distanceKm) ?? undefined : undefined;
|
|
return { distanceKm, altM, paceSec };
|
|
})
|
|
: null;
|
|
|
|
const hrData = hasHr
|
|
? grid.map((distanceKm) => ({ distanceKm, value: interpolate(hrSorted, distanceKm) ?? 0 }))
|
|
: null;
|
|
|
|
const gcbData = hasGcb
|
|
? grid.map((distanceKm) => {
|
|
const left = Math.round((interpolate(gcbSorted, distanceKm) ?? 50) * 10) / 10;
|
|
return { distanceKm, left, right: Math.round((100 - left) * 10) / 10 };
|
|
})
|
|
: null;
|
|
|
|
return { elevData, hrData, gcbData };
|
|
}
|
|
|
|
|
|
export default async function RunningActivityPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>;
|
|
}) {
|
|
const { id } = await params;
|
|
const userId = await getCurrentUserId();
|
|
const activity = await getRunningActivity(userId, id);
|
|
|
|
if (!activity) {
|
|
notFound();
|
|
}
|
|
|
|
const analysis = await getLatestAnalysisForTarget(userId, "running", activity._id);
|
|
const { elevData, hrData, gcbData } = buildChartData(activity);
|
|
|
|
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">
|
|
<RouteMap activity={activity} />
|
|
|
|
<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>
|
|
|
|
{elevData && <ElevationChart data={elevData} syncId="run-detail" />}
|
|
{(hrData || gcbData) && (
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
{hrData && (
|
|
<RunMetricChart data={hrData} label="Tętno" unit="bpm" color="var(--color-accent)" syncId="run-detail" />
|
|
)}
|
|
{gcbData && <GcbChart data={gcbData} syncId="run-detail" />}
|
|
</div>
|
|
)}
|
|
|
|
<AiAnalysisCard
|
|
targetType="running"
|
|
targetId={activity._id.toString()}
|
|
analysis={analysis ? serializeAnalysis(analysis) : null}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|