init
This commit is contained in:
@@ -1,25 +1,44 @@
|
||||
import { Suspense } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AiAnalysisCard } from "@/components/ai-analysis-card";
|
||||
import { ElevationChart } from "@/components/elevation-chart";
|
||||
import { RouteMapSection } from "@/components/route-map-section";
|
||||
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, getAuthorizedClient } from "@/lib/garmin/client";
|
||||
import {
|
||||
fetchActivityRoutePoints,
|
||||
fetchActivityRunMetrics,
|
||||
getAuthorizedClient,
|
||||
} from "@/lib/garmin/client";
|
||||
import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
|
||||
import { getRunningActivity, setRunningActivityRoutePoints, type RunningActivity } from "@/lib/models/running";
|
||||
import {
|
||||
getRunningActivity,
|
||||
setRunningActivityMetrics,
|
||||
setRunningActivityRoutePoints,
|
||||
type RunMetrics,
|
||||
type RunningActivity,
|
||||
} from "@/lib/models/running";
|
||||
|
||||
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 }) {
|
||||
let routePoints = activity.routePoints;
|
||||
|
||||
if (!routePoints && activity.hasRoute) {
|
||||
if ((!routePoints || !activity.elevationProfile) && mayHaveRoute(activity)) {
|
||||
try {
|
||||
const client = await getAuthorizedClient();
|
||||
const points = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||
if (points) {
|
||||
await setRunningActivityRoutePoints(activity.garminActivityId, points);
|
||||
routePoints = points;
|
||||
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||
if (result) {
|
||||
await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
|
||||
routePoints = result.points;
|
||||
}
|
||||
} catch {
|
||||
// GPS fetch failed silently
|
||||
@@ -39,6 +58,111 @@ async function RouteMapFetcher({ activity }: { activity: RunningActivity }) {
|
||||
);
|
||||
}
|
||||
|
||||
function hasValidElevation(profile: number[] | undefined): boolean {
|
||||
return Array.isArray(profile) && profile.some((v) => v > 0);
|
||||
}
|
||||
|
||||
async function ElevationFetcher({ activity }: { activity: RunningActivity }) {
|
||||
let elevationProfile = activity.elevationProfile;
|
||||
|
||||
if (!hasValidElevation(elevationProfile) && mayHaveRoute(activity)) {
|
||||
try {
|
||||
const client = await getAuthorizedClient();
|
||||
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||
if (result) {
|
||||
await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
|
||||
elevationProfile = result.elevationProfile;
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
if (!elevationProfile || elevationProfile.length < 2) return null;
|
||||
|
||||
const data = 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;
|
||||
|
||||
return <ElevationChart data={data} />;
|
||||
}
|
||||
|
||||
function toChartData(
|
||||
values: number[] | undefined,
|
||||
distances: number[]
|
||||
): { distanceKm: number; value: number }[] {
|
||||
if (!values) return [];
|
||||
return distances
|
||||
.map((distanceKm, i) => ({ distanceKm, value: values[i] ?? 0 }))
|
||||
.filter((p) => p.value > 0);
|
||||
}
|
||||
|
||||
async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) {
|
||||
let metrics: RunMetrics | undefined = activity.runMetrics;
|
||||
|
||||
const missingCadence = activity.avgCadence && !metrics?.cadenceSpm;
|
||||
const missingGcb = activity.avgGroundContactBalanceLeftPct && !metrics?.gcbLeftPct;
|
||||
|
||||
if ((!metrics || missingCadence || missingGcb) && mayHaveRoute(activity)) {
|
||||
try {
|
||||
const client = await getAuthorizedClient();
|
||||
const fetched = await fetchActivityRunMetrics(client, activity.garminActivityId);
|
||||
if (fetched) {
|
||||
await setRunningActivityMetrics(activity.garminActivityId, fetched);
|
||||
metrics = fetched;
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
if (!metrics || metrics.distanceKm.length === 0) return null;
|
||||
|
||||
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
|
||||
? metrics.distanceKm
|
||||
: Array.from({ length: metrics.distanceKm.length }, (_, i) =>
|
||||
Math.round(((i / (metrics.distanceKm.length - 1)) * activity.distanceM) / 10) / 100
|
||||
);
|
||||
|
||||
const hrData = toChartData(hrBpm, distanceKm);
|
||||
const gcbData = gcbLeftPct
|
||||
? distanceKm
|
||||
.map((d, i) => {
|
||||
const left = gcbLeftPct[i] ?? 0;
|
||||
return left > 0
|
||||
? { distanceKm: d, left, right: Math.round((100 - left) * 10) / 10 }
|
||||
: null;
|
||||
})
|
||||
.filter((p): p is NonNullable<typeof p> => p !== null)
|
||||
: [];
|
||||
|
||||
if (!hrData.length && !gcbData.length) return null;
|
||||
|
||||
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)"
|
||||
/>
|
||||
)}
|
||||
{gcbData.length > 1 && <GcbChart data={gcbData} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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" />
|
||||
@@ -67,22 +191,18 @@ export default async function RunningActivityPage({
|
||||
</div>
|
||||
|
||||
<section className="grid grid-cols-3 gap-4">
|
||||
{/* Map: cols 1–2, rows 1–3 — streamed in after page skeleton */}
|
||||
<Suspense fallback={<MapSkeleton />}>
|
||||
<RouteMapFetcher activity={activity} />
|
||||
</Suspense>
|
||||
|
||||
{/* Col 3, rows 1–3: key pace stats */}
|
||||
<StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} />
|
||||
<StatCard highlight label="Czas" value={formatDuration(activity.durationSec)} />
|
||||
<StatCard highlight label="Tempo" value={formatPace(activity.avgPaceSecPerKm)} />
|
||||
|
||||
{/* Row 4: HR, calories, cadence — always shown */}
|
||||
<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` : "—"} />
|
||||
|
||||
{/* Row 5+: optional advanced stats, auto-flow */}
|
||||
{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}
|
||||
@@ -117,6 +237,14 @@ export default async function RunningActivityPage({
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<ElevationFetcher activity={activity} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<RunMetricsFetcher activity={activity} />
|
||||
</Suspense>
|
||||
|
||||
<AiAnalysisCard targetType="running" targetId={activity._id.toString()} analysis={analysis ? serializeAnalysis(analysis) : null} />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user