init
This commit is contained in:
123
app/running/[id]/page.tsx
Normal file
123
app/running/[id]/page.tsx
Normal 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 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}
|
||||
{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
108
app/running/actions.ts
Normal 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
54
app/running/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user