init
This commit is contained in:
@@ -7,16 +7,9 @@ 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 {
|
||||
fetchActivityRoutePoints,
|
||||
fetchActivityRunMetrics,
|
||||
getAuthorizedClient,
|
||||
} from "@/lib/garmin/client";
|
||||
import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
|
||||
import {
|
||||
getRunningActivity,
|
||||
setRunningActivityMetrics,
|
||||
setRunningActivityRoutePoints,
|
||||
type RunMetrics,
|
||||
type RunningActivity,
|
||||
} from "@/lib/models/running";
|
||||
@@ -24,83 +17,34 @@ import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function mayHaveRoute(activity: RunningActivity): boolean {
|
||||
return Boolean(activity.hasRoute) || Boolean(activity.routePoints?.length);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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</span>
|
||||
<span className="text-sm text-fg/30">Brak danych GPS — zsynchronizuj ponownie</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function hasValidElevation(profile: number[] | undefined): boolean {
|
||||
return Array.isArray(profile) && profile.some((v) => v > 0);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function ElevationSection({ activity }: { activity: RunningActivity }) {
|
||||
const elevationProfile = activity.elevationProfile;
|
||||
if (!elevationProfile || elevationProfile.length < 2) return null;
|
||||
|
||||
const elevData = elevationProfile
|
||||
.map((altM, i) => ({
|
||||
distanceKm: Math.round((i / elevationProfile!.length) * activity.distanceM / 10) / 100,
|
||||
distanceKm: Math.round((i / elevationProfile.length) * activity.distanceM / 10) / 100,
|
||||
altM,
|
||||
}))
|
||||
.filter((p) => p.altM > 0);
|
||||
|
||||
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;
|
||||
@@ -122,33 +66,8 @@ function toChartData(
|
||||
.filter((p) => p.value > 0);
|
||||
}
|
||||
|
||||
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 || 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
|
||||
}
|
||||
}
|
||||
function RunMetricsSection({ activity }: { activity: RunningActivity }) {
|
||||
const metrics: RunMetrics | undefined = activity.runMetrics;
|
||||
|
||||
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({
|
||||
params,
|
||||
@@ -215,9 +129,7 @@ export default async function RunningActivityPage({
|
||||
</div>
|
||||
|
||||
<section className="grid grid-cols-3 gap-4">
|
||||
<Suspense fallback={<MapSkeleton />}>
|
||||
<RouteMapFetcher activity={activity} userId={userId} />
|
||||
</Suspense>
|
||||
<RouteMap activity={activity} />
|
||||
|
||||
<StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} />
|
||||
<StatCard highlight label="Czas" value={formatDuration(activity.durationSec)} />
|
||||
@@ -261,13 +173,8 @@ export default async function RunningActivityPage({
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<ElevationFetcher activity={activity} userId={userId} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<RunMetricsFetcher activity={activity} userId={userId} />
|
||||
</Suspense>
|
||||
<ElevationSection activity={activity} />
|
||||
<RunMetricsSection activity={activity} />
|
||||
|
||||
<AiAnalysisCard
|
||||
targetType="running"
|
||||
|
||||
@@ -14,10 +14,13 @@ import {
|
||||
import {
|
||||
getLastSyncAt,
|
||||
getRunningActivity,
|
||||
listRunningActivities,
|
||||
setLastSyncAt,
|
||||
setRunningActivityMetrics,
|
||||
setRunningActivityRoutePoints,
|
||||
upsertRunningActivity,
|
||||
} from "@/lib/models/running";
|
||||
import { fetchActivityRunMetrics } from "@/lib/garmin/client";
|
||||
import {
|
||||
clearPendingMfaState,
|
||||
getPendingMfaState,
|
||||
@@ -28,6 +31,9 @@ import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
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> {
|
||||
const since = await getLastSyncAt(userId);
|
||||
const activities = await fetchRunningActivities(client);
|
||||
@@ -39,6 +45,36 @@ async function syncWithClient(userId: string, client: GarminConnect): Promise<Sy
|
||||
|
||||
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("/settings");
|
||||
revalidatePath("/");
|
||||
|
||||
Reference in New Issue
Block a user