init
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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("/");
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user