init
This commit is contained in:
@@ -20,24 +20,29 @@ import {
|
||||
type RunMetrics,
|
||||
type RunningActivity,
|
||||
} from "@/lib/models/running";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
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 }) {
|
||||
async function RouteMapFetcher({
|
||||
activity,
|
||||
userId,
|
||||
}: {
|
||||
activity: RunningActivity;
|
||||
userId: string;
|
||||
}) {
|
||||
let routePoints = activity.routePoints;
|
||||
|
||||
if ((!routePoints || !activity.elevationProfile) && mayHaveRoute(activity)) {
|
||||
try {
|
||||
const client = await getAuthorizedClient();
|
||||
const client = await getAuthorizedClient(userId);
|
||||
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||
if (result) {
|
||||
await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
|
||||
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
|
||||
routePoints = result.points;
|
||||
}
|
||||
} catch {
|
||||
@@ -62,15 +67,21 @@ function hasValidElevation(profile: number[] | undefined): boolean {
|
||||
return Array.isArray(profile) && profile.some((v) => v > 0);
|
||||
}
|
||||
|
||||
async function ElevationFetcher({ activity }: { activity: RunningActivity }) {
|
||||
async function ElevationFetcher({
|
||||
activity,
|
||||
userId,
|
||||
}: {
|
||||
activity: RunningActivity;
|
||||
userId: string;
|
||||
}) {
|
||||
let elevationProfile = activity.elevationProfile;
|
||||
|
||||
if (!hasValidElevation(elevationProfile) && mayHaveRoute(activity)) {
|
||||
try {
|
||||
const client = await getAuthorizedClient();
|
||||
const client = await getAuthorizedClient(userId);
|
||||
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||
if (result) {
|
||||
await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
|
||||
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
|
||||
elevationProfile = result.elevationProfile;
|
||||
}
|
||||
} catch {
|
||||
@@ -80,14 +91,23 @@ async function ElevationFetcher({ activity }: { activity: RunningActivity }) {
|
||||
|
||||
if (!elevationProfile || elevationProfile.length < 2) return null;
|
||||
|
||||
const data = elevationProfile
|
||||
const elevData = 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;
|
||||
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;
|
||||
const pi = Math.min(Math.round((i / elevData.length) * paceSrc.length), paceSrc.length - 1);
|
||||
const v = paceSrc[pi];
|
||||
return { ...ep, paceSec: v > 0 && v < 1800 ? v : undefined };
|
||||
});
|
||||
|
||||
return <ElevationChart data={data} />;
|
||||
}
|
||||
@@ -102,18 +122,27 @@ function toChartData(
|
||||
.filter((p) => p.value > 0);
|
||||
}
|
||||
|
||||
async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) {
|
||||
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) && mayHaveRoute(activity)) {
|
||||
if ((!metrics || missingCadence || missingGcb || missingPace) && mayHaveRoute(activity)) {
|
||||
try {
|
||||
const client = await getAuthorizedClient();
|
||||
const client = await getAuthorizedClient(userId);
|
||||
const fetched = await fetchActivityRunMetrics(client, activity.garminActivityId);
|
||||
if (fetched) {
|
||||
await setRunningActivityMetrics(activity.garminActivityId, fetched);
|
||||
await setRunningActivityMetrics(userId, activity.garminActivityId, fetched);
|
||||
metrics = fetched;
|
||||
}
|
||||
} catch {
|
||||
@@ -125,7 +154,6 @@ async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) {
|
||||
|
||||
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
|
||||
@@ -151,12 +179,7 @@ async function RunMetricsFetcher({ activity }: { activity: RunningActivity }) {
|
||||
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)"
|
||||
/>
|
||||
<RunMetricChart data={hrData} label="Tętno" unit="bpm" color="var(--color-accent)" />
|
||||
)}
|
||||
{gcbData.length > 1 && <GcbChart data={gcbData} />}
|
||||
</div>
|
||||
@@ -175,13 +198,14 @@ export default async function RunningActivityPage({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const activity = await getRunningActivity(id);
|
||||
const userId = await getCurrentUserId();
|
||||
const activity = await getRunningActivity(userId, id);
|
||||
|
||||
if (!activity) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const analysis = await getLatestAnalysisForTarget("running", activity._id);
|
||||
const analysis = await getLatestAnalysisForTarget(userId, "running", activity._id);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -192,7 +216,7 @@ export default async function RunningActivityPage({
|
||||
|
||||
<section className="grid grid-cols-3 gap-4">
|
||||
<Suspense fallback={<MapSkeleton />}>
|
||||
<RouteMapFetcher activity={activity} />
|
||||
<RouteMapFetcher activity={activity} userId={userId} />
|
||||
</Suspense>
|
||||
|
||||
<StatCard highlight label="Dystans" value={formatDistance(activity.distanceM)} />
|
||||
@@ -238,14 +262,18 @@ export default async function RunningActivityPage({
|
||||
</section>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<ElevationFetcher activity={activity} />
|
||||
<ElevationFetcher activity={activity} userId={userId} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<RunMetricsFetcher activity={activity} />
|
||||
<RunMetricsFetcher activity={activity} userId={userId} />
|
||||
</Suspense>
|
||||
|
||||
<AiAnalysisCard targetType="running" targetId={activity._id.toString()} analysis={analysis ? serializeAnalysis(analysis) : null} />
|
||||
<AiAnalysisCard
|
||||
targetType="running"
|
||||
targetId={activity._id.toString()}
|
||||
analysis={analysis ? serializeAnalysis(analysis) : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import type { GarminConnect } from "garmin-connect";
|
||||
import {
|
||||
GarminLoginRequiredError,
|
||||
GarminCredentialsMissingError,
|
||||
beginGarminLogin,
|
||||
completeGarminMfaLogin,
|
||||
fetchActivityRoutePoints,
|
||||
@@ -23,19 +24,20 @@ import {
|
||||
saveOauth1Token,
|
||||
savePendingMfaState,
|
||||
} from "@/lib/models/garmin-auth";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export type SyncGarminState = { error: string } | { success: string } | { mfaRequired: true } | null;
|
||||
|
||||
async function syncWithClient(client: GarminConnect): Promise<SyncGarminState> {
|
||||
const since = await getLastSyncAt();
|
||||
async function syncWithClient(userId: string, client: GarminConnect): Promise<SyncGarminState> {
|
||||
const since = await getLastSyncAt(userId);
|
||||
const activities = await fetchRunningActivities(client);
|
||||
const newCount = activities.filter((activity) => !since || activity.startTime > since).length;
|
||||
|
||||
for (const activity of activities) {
|
||||
await upsertRunningActivity(activity);
|
||||
await upsertRunningActivity(userId, activity);
|
||||
}
|
||||
|
||||
await setLastSyncAt(new Date());
|
||||
await setLastSyncAt(userId, new Date());
|
||||
|
||||
revalidatePath("/running");
|
||||
revalidatePath("/settings");
|
||||
@@ -45,39 +47,45 @@ async function syncWithClient(client: GarminConnect): Promise<SyncGarminState> {
|
||||
}
|
||||
|
||||
export async function syncGarminActivities(): Promise<SyncGarminState> {
|
||||
const userId = await getCurrentUserId();
|
||||
|
||||
try {
|
||||
const client = await getAuthorizedClient();
|
||||
return await syncWithClient(client);
|
||||
const client = await getAuthorizedClient(userId);
|
||||
return await syncWithClient(userId, client);
|
||||
} catch (error) {
|
||||
if (error instanceof GarminCredentialsMissingError) {
|
||||
return { error: error.message };
|
||||
}
|
||||
if (!(error instanceof GarminLoginRequiredError)) {
|
||||
return { error: error instanceof Error ? error.message : "Synchronizacja z Garmin nie powiodła się." };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await beginGarminLogin();
|
||||
const result = await beginGarminLogin(userId);
|
||||
if ("mfaRequired" in result) {
|
||||
await savePendingMfaState(result.pendingState);
|
||||
await savePendingMfaState(userId, result.pendingState);
|
||||
return { mfaRequired: true };
|
||||
}
|
||||
await saveOauth1Token(result.oauth1Token);
|
||||
return await syncWithClient(result.client);
|
||||
await saveOauth1Token(userId, result.oauth1Token);
|
||||
return await syncWithClient(userId, 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();
|
||||
const userId = await getCurrentUserId();
|
||||
const pending = await getPendingMfaState(userId);
|
||||
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);
|
||||
await saveOauth1Token(userId, result.oauth1Token);
|
||||
await clearPendingMfaState(userId);
|
||||
return await syncWithClient(userId, result.client);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Weryfikacja kodu MFA nie powiodła się." };
|
||||
}
|
||||
@@ -86,12 +94,13 @@ export async function submitGarminMfaCode(code: string): Promise<SyncGarminState
|
||||
export type LoadRouteState = { error: string } | { success: true } | null;
|
||||
|
||||
export async function loadActivityRoute(activityMongoId: string): Promise<LoadRouteState> {
|
||||
const activity = await getRunningActivity(activityMongoId);
|
||||
const userId = await getCurrentUserId();
|
||||
const activity = await getRunningActivity(userId, activityMongoId);
|
||||
if (!activity) return { error: "Nie znaleziono aktywności." };
|
||||
|
||||
let client: GarminConnect;
|
||||
try {
|
||||
client = await getAuthorizedClient();
|
||||
client = await getAuthorizedClient(userId);
|
||||
} catch {
|
||||
return { error: "Brak połączenia z Garmin Connect. Wykonaj synchronizację." };
|
||||
}
|
||||
@@ -99,7 +108,7 @@ export async function loadActivityRoute(activityMongoId: string): Promise<LoadRo
|
||||
try {
|
||||
const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
|
||||
if (!result) return { error: "Brak danych GPS dla tej aktywności." };
|
||||
await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
|
||||
await setRunningActivityRoutePoints(userId, activity.garminActivityId, result.points, result.elevationProfile);
|
||||
revalidatePath(`/running/${activityMongoId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,11 +4,13 @@ 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";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RunningPage() {
|
||||
const activities = await listRunningActivities();
|
||||
const userId = await getCurrentUserId();
|
||||
const activities = await listRunningActivities(userId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
Reference in New Issue
Block a user