This commit is contained in:
Dominik Klarkowski
2026-06-16 09:43:48 +02:00
parent f0e87d8d11
commit 36407f534b
52 changed files with 3211 additions and 100 deletions

123
app/running/[id]/page.tsx Normal file
View File

@@ -0,0 +1,123 @@
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { AiAnalysisCard } from "@/components/ai-analysis-card";
import { RouteMapSection } from "@/components/route-map-section";
import { StatCard } from "@/components/stat-card";
import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format";
import { fetchActivityRoutePoints, getAuthorizedClient } from "@/lib/garmin/client";
import { getLatestAnalysisForTarget } from "@/lib/models/analysis";
import { getRunningActivity, setRunningActivityRoutePoints, type RunningActivity } from "@/lib/models/running";
export const dynamic = "force-dynamic";
async function RouteMapFetcher({ activity }: { activity: RunningActivity }) {
let routePoints = activity.routePoints;
if (!routePoints && activity.hasRoute) {
try {
const client = await getAuthorizedClient();
const points = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (points) {
await setRunningActivityRoutePoints(activity.garminActivityId, points);
routePoints = points;
}
} catch {
// GPS fetch failed silently
}
}
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>
</div>
)}
</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" />
);
}
export default async function RunningActivityPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const activity = await getRunningActivity(id);
if (!activity) {
notFound();
}
const analysis = await getLatestAnalysisForTarget("running", activity._id);
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-fg">{activity.name}</h1>
<p className="mt-1 text-sm text-fg/60">{formatDate(activity.startTime)}</p>
</div>
<section className="grid grid-cols-3 gap-4">
{/* Map: cols 12, rows 13 — streamed in after page skeleton */}
<Suspense fallback={<MapSkeleton />}>
<RouteMapFetcher activity={activity} />
</Suspense>
{/* Col 3, rows 13: 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}
{activity.avgGroundContactTimeMs ? (
<StatCard label="Czas kontaktu z podłożem" value={`${Math.round(activity.avgGroundContactTimeMs)} ms`} />
) : null}
{activity.avgVerticalOscillationCm ? (
<StatCard label="Oscylacja wertykalna" value={`${activity.avgVerticalOscillationCm.toFixed(1)} cm`} />
) : null}
{activity.avgVerticalRatioPct ? (
<StatCard label="Wskaźnik wertykalny" value={`${activity.avgVerticalRatioPct.toFixed(1)}%`} />
) : null}
{activity.avgStrideLengthCm ? (
<StatCard label="Długość kroku" value={`${activity.avgStrideLengthCm.toFixed(0)} cm`} />
) : null}
{activity.avgGroundContactBalanceLeftPct ? (
<StatCard
label="Balans kontaktu (L/P)"
value={`${activity.avgGroundContactBalanceLeftPct.toFixed(1)}% / ${(100 - activity.avgGroundContactBalanceLeftPct).toFixed(1)}%`}
/>
) : null}
{activity.avgPowerW ? <StatCard label="Moc średnia" value={`${Math.round(activity.avgPowerW)} W`} /> : null}
{activity.maxPowerW ? <StatCard label="Moc maks." value={`${Math.round(activity.maxPowerW)} W`} /> : null}
{activity.avgRespirationRate ? (
<StatCard label="Częstość oddechów" value={`${activity.avgRespirationRate.toFixed(1)} /min`} />
) : null}
{activity.aerobicTrainingEffect ? (
<StatCard label="Efekt aerobowy" value={activity.aerobicTrainingEffect.toFixed(1)} />
) : null}
{activity.anaerobicTrainingEffect ? (
<StatCard label="Efekt anaerobowy" value={activity.anaerobicTrainingEffect.toFixed(1)} />
) : null}
</section>
<AiAnalysisCard targetType="running" targetId={activity._id.toString()} analysis={analysis} />
</div>
);
}

108
app/running/actions.ts Normal file
View File

@@ -0,0 +1,108 @@
"use server";
import { revalidatePath } from "next/cache";
import type { GarminConnect } from "garmin-connect";
import {
GarminLoginRequiredError,
beginGarminLogin,
completeGarminMfaLogin,
fetchActivityRoutePoints,
fetchRunningActivities,
getAuthorizedClient,
} from "@/lib/garmin/client";
import {
getLastSyncAt,
getRunningActivity,
setLastSyncAt,
setRunningActivityRoutePoints,
upsertRunningActivity,
} from "@/lib/models/running";
import {
clearPendingMfaState,
getPendingMfaState,
saveOauth1Token,
savePendingMfaState,
} from "@/lib/models/garmin-auth";
export type SyncGarminState = { error: string } | { success: string } | { mfaRequired: true } | null;
async function syncWithClient(client: GarminConnect): Promise<SyncGarminState> {
const since = await getLastSyncAt();
const activities = await fetchRunningActivities(client);
const newCount = activities.filter((activity) => !since || activity.startTime > since).length;
for (const activity of activities) {
await upsertRunningActivity(activity);
}
await setLastSyncAt(new Date());
revalidatePath("/running");
revalidatePath("/settings");
revalidatePath("/");
return { success: `Zsynchronizowano ${newCount} nowych aktywności (zaktualizowano ${activities.length}).` };
}
export async function syncGarminActivities(): Promise<SyncGarminState> {
try {
const client = await getAuthorizedClient();
return await syncWithClient(client);
} catch (error) {
if (!(error instanceof GarminLoginRequiredError)) {
return { error: error instanceof Error ? error.message : "Synchronizacja z Garmin nie powiodła się." };
}
}
try {
const result = await beginGarminLogin();
if ("mfaRequired" in result) {
await savePendingMfaState(result.pendingState);
return { mfaRequired: true };
}
await saveOauth1Token(result.oauth1Token);
return await syncWithClient(result.client);
} catch (error) {
return { error: error instanceof Error ? error.message : "Logowanie do Garmin nie powiodło się." };
}
}
export async function submitGarminMfaCode(code: string): Promise<SyncGarminState> {
const pending = await getPendingMfaState();
if (!pending) {
return { error: "Sesja logowania do Garmin wygasła. Kliknij \"Synchronizuj z Garmin\" ponownie." };
}
try {
const result = await completeGarminMfaLogin(pending, code);
await saveOauth1Token(result.oauth1Token);
await clearPendingMfaState();
return await syncWithClient(result.client);
} catch (error) {
return { error: error instanceof Error ? error.message : "Weryfikacja kodu MFA nie powiodła się." };
}
}
export type LoadRouteState = { error: string } | { success: true } | null;
export async function loadActivityRoute(activityMongoId: string): Promise<LoadRouteState> {
const activity = await getRunningActivity(activityMongoId);
if (!activity) return { error: "Nie znaleziono aktywności." };
let client: GarminConnect;
try {
client = await getAuthorizedClient();
} catch {
return { error: "Brak połączenia z Garmin Connect. Wykonaj synchronizację." };
}
try {
const points = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (!points) return { error: "Brak danych GPS dla tej aktywności." };
await setRunningActivityRoutePoints(activity.garminActivityId, points);
revalidatePath(`/running/${activityMongoId}`);
return { success: true };
} catch (error) {
return { error: error instanceof Error ? error.message : "Nie udało się pobrać mapy trasy." };
}
}

54
app/running/page.tsx Normal file
View File

@@ -0,0 +1,54 @@
import Link from "next/link";
import { Activity } from "lucide-react";
import { EmptyState } from "@/components/empty-state";
import { SyncButton } from "@/components/sync-button";
import { formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format";
import { listRunningActivities } from "@/lib/models/running";
export const dynamic = "force-dynamic";
export default async function RunningPage() {
const activities = await listRunningActivities();
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-fg">Bieganie</h1>
<p className="mt-1 text-sm text-fg/60">Aktywności zsynchronizowane z Garmin Connect.</p>
</div>
<SyncButton />
</div>
{activities.length === 0 ? (
<EmptyState
icon={<Activity size={32} />}
title="Brak biegów"
description="Zsynchronizuj aktywności z Garmin Connect, aby zobaczyć tutaj swoje biegi."
/>
) : (
<ul className="flex flex-col gap-3">
{activities.map((activity) => (
<li key={activity._id.toString()}>
<Link
href={`/running/${activity._id.toString()}`}
className="flex items-center justify-between rounded-lg border border-muted/40 bg-surface p-4 transition-colors hover:border-accent/60"
>
<div>
<div className="font-semibold text-fg">{activity.name}</div>
<div className="text-sm text-fg/60">{formatDateShort(activity.startTime)}</div>
</div>
<div className="flex flex-col items-end text-sm text-fg/60">
<span>{formatDistance(activity.distanceM)}</span>
<span>
{formatDuration(activity.durationSec)} · {formatPace(activity.avgPaceSecPerKm)}
</span>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
);
}