This commit is contained in:
Dominik Klarkowski
2026-06-18 11:24:56 +02:00
parent 115d56cd12
commit 63cb8b4933
3 changed files with 48 additions and 105 deletions

View File

@@ -7,16 +7,9 @@ import { GcbChart } from "@/components/gcb-chart";
import { RunMetricChart } from "@/components/run-metric-chart"; import { RunMetricChart } from "@/components/run-metric-chart";
import { StatCard } from "@/components/stat-card"; import { StatCard } from "@/components/stat-card";
import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format"; import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format";
import {
fetchActivityRoutePoints,
fetchActivityRunMetrics,
getAuthorizedClient,
} from "@/lib/garmin/client";
import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis"; import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
import { import {
getRunningActivity, getRunningActivity,
setRunningActivityMetrics,
setRunningActivityRoutePoints,
type RunMetrics, type RunMetrics,
type RunningActivity, type RunningActivity,
} from "@/lib/models/running"; } from "@/lib/models/running";
@@ -24,83 +17,34 @@ import { getCurrentUserId } from "@/lib/session";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
function mayHaveRoute(activity: RunningActivity): boolean { function RouteMap({ activity }: { activity: RunningActivity }) {
return Boolean(activity.hasRoute) || Boolean(activity.routePoints?.length); const routePoints = activity.routePoints;
}
async function RouteMapFetcher({
activity,
userId,
}: {
activity: RunningActivity;
userId: string;
}) {
let routePoints = activity.routePoints;
if ((!routePoints || !activity.elevationProfile) && mayHaveRoute(activity)) {
try {
const client = await getAuthorizedClient(userId);
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (result) {
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
routePoints = result.points;
}
} catch {
// GPS fetch failed silently
}
}
return ( return (
<div className="col-span-2 row-span-3 min-h-[240px] overflow-hidden rounded-lg border border-muted/40"> <div className="col-span-2 row-span-3 min-h-[240px] overflow-hidden rounded-lg border border-muted/40">
{routePoints && routePoints.length > 0 ? ( {routePoints && routePoints.length > 0 ? (
<RouteMapSection points={routePoints} /> <RouteMapSection points={routePoints} />
) : ( ) : (
<div className="flex h-full min-h-[240px] items-center justify-center bg-surface"> <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> <span className="text-sm text-fg/30">Brak danych GPS zsynchronizuj ponownie</span>
</div> </div>
)} )}
</div> </div>
); );
} }
function hasValidElevation(profile: number[] | undefined): boolean { function ElevationSection({ activity }: { activity: RunningActivity }) {
return Array.isArray(profile) && profile.some((v) => v > 0); const elevationProfile = activity.elevationProfile;
}
async function ElevationFetcher({
activity,
userId,
}: {
activity: RunningActivity;
userId: string;
}) {
let elevationProfile = activity.elevationProfile;
if (!hasValidElevation(elevationProfile) && mayHaveRoute(activity)) {
try {
const client = await getAuthorizedClient(userId);
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (result) {
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
elevationProfile = result.elevationProfile;
}
} catch {
// silent
}
}
if (!elevationProfile || elevationProfile.length < 2) return null; if (!elevationProfile || elevationProfile.length < 2) return null;
const elevData = elevationProfile const elevData = elevationProfile
.map((altM, i) => ({ .map((altM, i) => ({
distanceKm: Math.round((i / elevationProfile!.length) * activity.distanceM / 10) / 100, distanceKm: Math.round((i / elevationProfile.length) * activity.distanceM / 10) / 100,
altM, altM,
})) }))
.filter((p) => p.altM > 0); .filter((p) => p.altM > 0);
if (elevData.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 paceSrc = activity.runMetrics?.paceSec;
const data = elevData.map((ep, i) => { const data = elevData.map((ep, i) => {
if (!paceSrc || paceSrc.length === 0) return ep; if (!paceSrc || paceSrc.length === 0) return ep;
@@ -122,33 +66,8 @@ function toChartData(
.filter((p) => p.value > 0); .filter((p) => p.value > 0);
} }
async function RunMetricsFetcher({ function RunMetricsSection({ activity }: { activity: RunningActivity }) {
activity, const metrics: RunMetrics | undefined = activity.runMetrics;
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 || missingPace) && mayHaveRoute(activity)) {
try {
const client = await getAuthorizedClient(userId);
const fetched = await fetchActivityRunMetrics(client, activity.garminActivityId);
if (fetched) {
await setRunningActivityMetrics(userId, activity.garminActivityId, fetched);
metrics = fetched;
}
} catch {
// silent
}
}
if (!metrics || metrics.distanceKm.length === 0) return null; if (!metrics || metrics.distanceKm.length === 0) return null;
@@ -186,11 +105,6 @@ async function RunMetricsFetcher({
); );
} }
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({ export default async function RunningActivityPage({
params, params,
@@ -215,9 +129,7 @@ export default async function RunningActivityPage({
</div> </div>
<section className="grid grid-cols-3 gap-4"> <section className="grid grid-cols-3 gap-4">
<Suspense fallback={<MapSkeleton />}> <RouteMap activity={activity} />
<RouteMapFetcher activity={activity} userId={userId} />
</Suspense>
<StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} /> <StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} />
<StatCard highlight label="Czas" value={formatDuration(activity.durationSec)} /> <StatCard highlight label="Czas" value={formatDuration(activity.durationSec)} />
@@ -261,13 +173,8 @@ export default async function RunningActivityPage({
) : null} ) : null}
</section> </section>
<Suspense fallback={null}> <ElevationSection activity={activity} />
<ElevationFetcher activity={activity} userId={userId} /> <RunMetricsSection activity={activity} />
</Suspense>
<Suspense fallback={null}>
<RunMetricsFetcher activity={activity} userId={userId} />
</Suspense>
<AiAnalysisCard <AiAnalysisCard
targetType="running" targetType="running"

View File

@@ -14,10 +14,13 @@ import {
import { import {
getLastSyncAt, getLastSyncAt,
getRunningActivity, getRunningActivity,
listRunningActivities,
setLastSyncAt, setLastSyncAt,
setRunningActivityMetrics,
setRunningActivityRoutePoints, setRunningActivityRoutePoints,
upsertRunningActivity, upsertRunningActivity,
} from "@/lib/models/running"; } from "@/lib/models/running";
import { fetchActivityRunMetrics } from "@/lib/garmin/client";
import { import {
clearPendingMfaState, clearPendingMfaState,
getPendingMfaState, getPendingMfaState,
@@ -28,6 +31,9 @@ import { getCurrentUserId } from "@/lib/session";
export type SyncGarminState = { error: string } | { success: string } | { mfaRequired: true } | null; export type SyncGarminState = { error: string } | { success: string } | { mfaRequired: true } | null;
// How many activities without data to enrich per sync (limits API call volume)
const ENRICH_PER_SYNC = 10;
async function syncWithClient(userId: string, client: GarminConnect): Promise<SyncGarminState> { async function syncWithClient(userId: string, client: GarminConnect): Promise<SyncGarminState> {
const since = await getLastSyncAt(userId); const since = await getLastSyncAt(userId);
const activities = await fetchRunningActivities(client); const activities = await fetchRunningActivities(client);
@@ -39,6 +45,36 @@ async function syncWithClient(userId: string, client: GarminConnect): Promise<Sy
await setLastSyncAt(userId, new Date()); await setLastSyncAt(userId, new Date());
// Enrich activities missing route/metrics — fetched during sync so page loads are instant
const all = await listRunningActivities(userId);
const needsEnrich = all
.filter((a) => a.hasRoute && (!a.routePoints?.length || !a.runMetrics?.paceSec))
.slice(0, ENRICH_PER_SYNC);
for (const activity of needsEnrich) {
try {
if (!activity.routePoints?.length) {
const route = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (route) {
await setRunningActivityRoutePoints(
userId,
activity.garminActivityId,
route.points,
route.elevationProfile
);
}
}
if (!activity.runMetrics?.paceSec) {
const metrics = await fetchActivityRunMetrics(client, activity.garminActivityId);
if (metrics) {
await setRunningActivityMetrics(userId, activity.garminActivityId, metrics);
}
}
} catch {
// Rate limited or activity has no GPS — skip silently
}
}
revalidatePath("/running"); revalidatePath("/running");
revalidatePath("/settings"); revalidatePath("/settings");
revalidatePath("/"); revalidatePath("/");

View File

@@ -53,7 +53,7 @@ export function ElevationChart({ data }: Props) {
}; };
return ( return (
<div className="rounded-lg border border-muted/40 bg-surface p-4"> <div className="w-full rounded-lg border border-muted/40 bg-surface p-4">
<div className="mb-2 flex items-center gap-4 text-sm text-fg/60"> <div className="mb-2 flex items-center gap-4 text-sm text-fg/60">
<span>Profil wysokości</span> <span>Profil wysokości</span>
{hasPace && ( {hasPace && (