This commit is contained in:
Dominik Klarkowski
2026-06-18 11:02:31 +02:00
parent d00a5a42ac
commit 047e580da0
32 changed files with 735 additions and 189 deletions

View File

@@ -20,24 +20,29 @@ import {
type RunMetrics,
type RunningActivity,
} from "@/lib/models/running";
import { getCurrentUserId } from "@/lib/session";
export const dynamic = "force-dynamic";
// hasRoute may be undefined for activities synced before the field was added,
// even if routePoints are already in the DB.
function mayHaveRoute(activity: RunningActivity): boolean {
return Boolean(activity.hasRoute) || Boolean(activity.routePoints?.length);
}
async function RouteMapFetcher({ activity }: { activity: RunningActivity }) {
async function RouteMapFetcher({
activity,
userId,
}: {
activity: RunningActivity;
userId: string;
}) {
let routePoints = activity.routePoints;
if ((!routePoints || !activity.elevationProfile) && mayHaveRoute(activity)) {
try {
const client = await getAuthorizedClient();
const client = await getAuthorizedClient(userId);
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (result) {
await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
routePoints = result.points;
}
} catch {
@@ -62,15 +67,21 @@ function hasValidElevation(profile: number[] | undefined): boolean {
return Array.isArray(profile) && profile.some((v) => v > 0);
}
async function ElevationFetcher({ activity }: { activity: RunningActivity }) {
async function ElevationFetcher({
activity,
userId,
}: {
activity: RunningActivity;
userId: string;
}) {
let elevationProfile = activity.elevationProfile;
if (!hasValidElevation(elevationProfile) && mayHaveRoute(activity)) {
try {
const client = await getAuthorizedClient();
const client = await getAuthorizedClient(userId);
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (result) {
await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
elevationProfile = result.elevationProfile;
}
} catch {
@@ -80,14 +91,23 @@ async function ElevationFetcher({ activity }: { activity: RunningActivity }) {
if (!elevationProfile || elevationProfile.length < 2) return null;
const data = elevationProfile
const elevData = elevationProfile
.map((altM, i) => ({
distanceKm: Math.round((i / elevationProfile!.length) * activity.distanceM / 10) / 100,
altM,
}))
.filter((p) => p.altM > 0);
if (data.length < 2) return null;
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 };
});
return <ElevationChart data={data} />;
}
@@ -102,18 +122,27 @@ function toChartData(
.filter((p) => p.value > 0);
}
async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) {
async function RunMetricsFetcher({
activity,
userId,
}: {
activity: RunningActivity;
userId: string;
}) {
let metrics: RunMetrics | undefined = activity.runMetrics;
const missingCadence = activity.avgCadence && !metrics?.cadenceSpm;
const missingGcb = activity.avgGroundContactBalanceLeftPct && !metrics?.gcbLeftPct;
// 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;
if ((!metrics || missingCadence || missingGcb) && mayHaveRoute(activity)) {
if ((!metrics || missingCadence || missingGcb || missingPace) && mayHaveRoute(activity)) {
try {
const client = await getAuthorizedClient();
const client = await getAuthorizedClient(userId);
const fetched = await fetchActivityRunMetrics(client, activity.garminActivityId);
if (fetched) {
await setRunningActivityMetrics(activity.garminActivityId, fetched);
await setRunningActivityMetrics(userId, activity.garminActivityId, fetched);
metrics = fetched;
}
} catch {
@@ -125,7 +154,6 @@ async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) {
const { hrBpm, gcbLeftPct } = metrics;
// Fall back to evenly-spaced distances if Garmin didn't provide them
const maxDist = Math.max(...metrics.distanceKm);
const distanceKm =
maxDist > 0
@@ -151,12 +179,7 @@ async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{hrData.length > 1 && (
<RunMetricChart
data={hrData}
label="Tętno"
unit="bpm"
color="var(--color-accent)"
/>
<RunMetricChart data={hrData} label="Tętno" unit="bpm" color="var(--color-accent)" />
)}
{gcbData.length > 1 && <GcbChart data={gcbData} />}
</div>
@@ -175,13 +198,14 @@ export default async function RunningActivityPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const activity = await getRunningActivity(id);
const userId = await getCurrentUserId();
const activity = await getRunningActivity(userId, id);
if (!activity) {
notFound();
}
const analysis = await getLatestAnalysisForTarget("running", activity._id);
const analysis = await getLatestAnalysisForTarget(userId, "running", activity._id);
return (
<div className="flex flex-col gap-6">
@@ -192,7 +216,7 @@ export default async function RunningActivityPage({
<section className="grid grid-cols-3 gap-4">
<Suspense fallback={<MapSkeleton />}>
<RouteMapFetcher activity={activity} />
<RouteMapFetcher activity={activity} userId={userId} />
</Suspense>
<StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} />
@@ -238,14 +262,18 @@ export default async function RunningActivityPage({
</section>
<Suspense fallback={null}>
<ElevationFetcher activity={activity} />
<ElevationFetcher activity={activity} userId={userId} />
</Suspense>
<Suspense fallback={null}>
<RunMetricsFetcher activity={activity} />
<RunMetricsFetcher activity={activity} userId={userId} />
</Suspense>
<AiAnalysisCard targetType="running" targetId={activity._id.toString()} analysis={analysis ? serializeAnalysis(analysis) : null} />
<AiAnalysisCard
targetType="running"
targetId={activity._id.toString()}
analysis={analysis ? serializeAnalysis(analysis) : null}
/>
</div>
);
}