Files

209 lines
8.1 KiB
TypeScript
Raw Permalink Normal View History

2026-06-16 09:43:48 +02:00
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-16 11:51:10 +02:00
import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
2026-06-18 09:43:25 +02:00
import {
getRunningActivity,
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 12:23:05 +02:00
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
);
}
2026-06-18 11:24:56 +02:00
function RouteMap({ activity }: { activity: RunningActivity }) {
const routePoints = activity.routePoints;
2026-06-16 09:43:48 +02:00
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">
2026-06-18 11:24:56 +02:00
<span className="text-sm text-fg/30">Brak danych GPS zsynchronizuj ponownie</span>
2026-06-16 09:43:48 +02:00
</div>
)}
</div>
);
}
2026-06-18 12:23:05 +02:00
function buildChartData(activity: RunningActivity) {
const maxDistKm = Math.round(activity.distanceM / 10) / 100;
const grid = buildGrid(maxDistKm);
2026-06-18 09:43:25 +02:00
2026-06-18 12:23:05 +02:00
const elevProfile = activity.elevationProfile;
const metrics = activity.runMetrics;
2026-06-18 11:02:31 +02:00
2026-06-18 12:23:05 +02:00
const elevSorted = elevProfile
? elevProfile
.map((altM, i) => ({ dist: (i / elevProfile.length) * maxDistKm, value: altM }))
.filter((p) => p.value > 0)
: [];
2026-06-18 09:43:25 +02:00
2026-06-18 12:23:05 +02:00
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);
})()
: [];
2026-06-18 09:43:25 +02:00
2026-06-18 12:23:05 +02:00
const hrSorted = metrics?.hrBpm
? metricsDistKm
.map((d, i) => ({ dist: d, value: metrics.hrBpm![i] ?? 0 }))
.filter((p) => p.value > 0)
: [];
2026-06-18 09:43:25 +02:00
2026-06-18 12:23:05 +02:00
const paceSorted = metrics?.paceSec
? metricsDistKm
.map((d, i) => ({ dist: d, value: metrics.paceSec![i] ?? 0 }))
.filter((p) => p.value > 0 && p.value < 1800)
2026-06-18 09:43:25 +02:00
: [];
2026-06-18 12:23:05 +02:00
const gcbSorted = metrics?.gcbLeftPct
? metricsDistKm
.map((d, i) => ({ dist: d, value: metrics.gcbLeftPct![i] ?? 0 }))
.filter((p) => p.value > 0)
: [];
2026-06-18 09:43:25 +02:00
2026-06-18 12:23:05 +02:00
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 };
2026-06-18 09:43:25 +02:00
}
2026-06-16 09:43:48 +02:00
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-18 12:23:05 +02:00
const { elevData, hrData, gcbData } = buildChartData(activity);
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">
2026-06-18 11:24:56 +02:00
<RouteMap activity={activity} />
2026-06-16 09:43:48 +02:00
<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 12:23:05 +02:00
{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>
)}
2026-06-18 09:43:25 +02:00
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>
);
}