This commit is contained in:
Dominik Klarkowski
2026-06-18 09:43:25 +02:00
parent ee178feff0
commit d00a5a42ac
9 changed files with 621 additions and 21 deletions

6
.idea/db-forest-config.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:cad974ec-67a1-45af-aa5e-0f2afd65b89b&#10;2:0:25e55397-437c-4c68-99f5-2d21fbba46d2&#10;3:0:d401f651-e4ab-4ff5-9655-e2b6c0b396dc&#10;4:0:ec87ba30-ade3-4e14-8f35-5c06f6cf8662&#10;5:0:e9fca1ad-1d37-4a8c-a7b4-95d6a736484c&#10;6:0:c4b8376d-a287-4fa4-bc44-2d2fac1cd37b&#10;7:0:66be42c8-917f-4bb8-ae8d-4016f762e870&#10;8:0:0b74125a-9a07-4cfa-afd4-c5fccf1496b4&#10;9:0:07d79dcf-bacf-49b0-936e-5cb4c251b3e1&#10;10:0:986e8611-b865-4ec3-8cb6-92fc42c283a7&#10;11:0:70ccb5fc-6aef-4cd5-9f4b-ccbea8e185c6&#10;12:0:d2358406-bd5f-4030-a822-5a1c2f653b55&#10;13:0:52d85314-8e9f-4e42-bd9d-f146df941871&#10;14:0:07d72045-e35c-4264-b612-a64c58c635d7&#10;15:0:c05a40be-4e30-4606-bc06-511d9b109dbb&#10;16:0:810ba5f7-c9cc-4010-a3c3-195abacabb8e&#10;17:0:5da9a988-52c5-4bcf-b758-1677ab67bf26&#10;18:0:7d0a6ab5-b6df-4898-afec-cad19b908728&#10;19:0:bd763ebe-280e-49f8-a248-fcd7c4c8d712&#10;20:0:233739c2-121e-45b9-be8b-613eadecd69f&#10;21:0:e8230f4d-40da-406b-a7ca-bf59ada3230a&#10;22:0:02b1616a-5718-42d3-a31e-22f9f32e5333&#10;23:0:1e611642-8fd8-44d0-876b-9c8c2b4ba00c&#10;" />
</component>
</project>

View File

@@ -1,25 +1,44 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { AiAnalysisCard } from "@/components/ai-analysis-card"; import { AiAnalysisCard } from "@/components/ai-analysis-card";
import { ElevationChart } from "@/components/elevation-chart";
import { RouteMapSection } from "@/components/route-map-section"; 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 { StatCard } from "@/components/stat-card";
import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/format"; 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 { 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"; 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 }: { activity: RunningActivity }) {
let routePoints = activity.routePoints; let routePoints = activity.routePoints;
if (!routePoints && activity.hasRoute) { if ((!routePoints || !activity.elevationProfile) && mayHaveRoute(activity)) {
try { try {
const client = await getAuthorizedClient(); const client = await getAuthorizedClient();
const points = await fetchActivityRoutePoints(client, activity.garminActivityId); const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (points) { if (result) {
await setRunningActivityRoutePoints(activity.garminActivityId, points); await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
routePoints = points; routePoints = result.points;
} }
} catch { } catch {
// GPS fetch failed silently // 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() { function MapSkeleton() {
return ( return (
<div className="col-span-2 row-span-3 min-h-[240px] animate-pulse overflow-hidden rounded-lg border border-muted/40 bg-surface" /> <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> </div>
<section className="grid grid-cols-3 gap-4"> <section className="grid grid-cols-3 gap-4">
{/* Map: cols 12, rows 13 — streamed in after page skeleton */}
<Suspense fallback={<MapSkeleton />}> <Suspense fallback={<MapSkeleton />}>
<RouteMapFetcher activity={activity} /> <RouteMapFetcher activity={activity} />
</Suspense> </Suspense>
{/* Col 3, rows 13: key pace stats */}
<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)} />
<StatCard highlight label="Tempo" value={formatPace(activity.avgPaceSecPerKm)} /> <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="Ś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="Kalorie" value={activity.calories ? `${Math.round(activity.calories)} kcal` : "—"} />
<StatCard highlight label="Kadencja" value={activity.avgCadence ? `${Math.round(activity.avgCadence)} kr/min` : "—"} /> <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.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.elevationGainM ? <StatCard label="Podejście" value={`${Math.round(activity.elevationGainM)} m`} /> : null}
{activity.vo2Max ? <StatCard label="VO2max" value={`${Math.round(activity.vo2Max)}`} /> : null} {activity.vo2Max ? <StatCard label="VO2max" value={`${Math.round(activity.vo2Max)}`} /> : null}
@@ -117,6 +237,14 @@ export default async function RunningActivityPage({
) : null} ) : null}
</section> </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} /> <AiAnalysisCard targetType="running" targetId={activity._id.toString()} analysis={analysis ? serializeAnalysis(analysis) : null} />
</div> </div>
); );

View File

@@ -97,9 +97,9 @@ export async function loadActivityRoute(activityMongoId: string): Promise<LoadRo
} }
try { try {
const points = await fetchActivityRoutePoints(client, activity.garminActivityId); const result = await fetchActivityRoutePoints(client, activity.garminActivityId);
if (!points) return { error: "Brak danych GPS dla tej aktywności." }; if (!result) return { error: "Brak danych GPS dla tej aktywności." };
await setRunningActivityRoutePoints(activity.garminActivityId, points); await setRunningActivityRoutePoints(activity.garminActivityId, result.points, result.elevationProfile);
revalidatePath(`/running/${activityMongoId}`); revalidatePath(`/running/${activityMongoId}`);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
type Props = {
data: { distanceKm: number; altM: number }[];
};
export function ElevationChart({ data }: Props) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <div className="h-[140px] animate-pulse rounded-lg border border-muted/40 bg-surface" />;
}
const altitudes = data.map((p) => p.altM);
const minAlt = Math.min(...altitudes);
const maxAlt = Math.max(...altitudes);
const pad = Math.max(5, Math.round((maxAlt - minAlt) * 0.15));
return (
<div className="rounded-lg border border-muted/40 bg-surface p-4">
<div className="mb-2 text-sm text-fg/60">Profil wysokości</div>
<ResponsiveContainer width="100%" height={110}>
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="elevGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-accent)" stopOpacity={0.25} />
<stop offset="95%" stopColor="var(--color-accent)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="var(--color-muted)" opacity={0.3} vertical={false} />
<XAxis
dataKey="distanceKm"
stroke="var(--color-fg)"
opacity={0.5}
fontSize={11}
tickFormatter={(v) => `${Number(v).toFixed(1)} km`}
interval={Math.max(0, Math.floor(data.length / 5) - 1)}
/>
<YAxis
stroke="var(--color-fg)"
opacity={0.5}
fontSize={11}
width={44}
tickFormatter={(v) => `${Math.round(v)} m`}
domain={[minAlt - pad, maxAlt + pad]}
/>
<Tooltip
contentStyle={{
background: "var(--color-bg)",
border: "1px solid var(--color-muted)",
borderRadius: 8,
fontSize: 12,
color: "var(--color-fg)",
}}
formatter={(value) => [`${Math.round(Number(value))} m n.p.m.`, "Wysokość"]}
labelFormatter={(label) => `${Number(label).toFixed(2)} km`}
/>
<Area
type="monotone"
dataKey="altM"
stroke="var(--color-accent)"
strokeWidth={2}
fill="url(#elevGradient)"
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}

110
components/gcb-chart.tsx Normal file
View File

@@ -0,0 +1,110 @@
"use client";
import { useEffect, useId, useState } from "react";
import {
Area,
AreaChart,
CartesianGrid,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
type GcbPoint = { distanceKm: number; left: number; right: number };
type Props = {
data: GcbPoint[];
};
export function GcbChart({ data }: Props) {
const uid = useId();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <div className="h-[160px] animate-pulse rounded-lg border border-muted/40 bg-surface" />;
}
return (
<div className="rounded-lg border border-muted/40 bg-surface p-4">
<div className="mb-2 flex items-center gap-4 text-sm text-fg/60">
<span>Balans kontaktu</span>
<span className="flex items-center gap-1">
<span className="inline-block h-2 w-4 rounded" style={{ background: "var(--color-accent)" }} />
Lewa
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-2 w-4 rounded" style={{ background: "var(--color-sand)" }} />
Prawa
</span>
</div>
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={`gcb-l-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-accent)" stopOpacity={0.2} />
<stop offset="95%" stopColor="var(--color-accent)" stopOpacity={0} />
</linearGradient>
<linearGradient id={`gcb-r-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-sand)" stopOpacity={0.2} />
<stop offset="95%" stopColor="var(--color-sand)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="var(--color-muted)" opacity={0.3} vertical={false} />
<XAxis
dataKey="distanceKm"
stroke="var(--color-fg)"
opacity={0.5}
fontSize={11}
tickFormatter={(v) => `${Number(v).toFixed(1)} km`}
interval={Math.max(0, Math.floor(data.length / 5) - 1)}
/>
<YAxis
stroke="var(--color-fg)"
opacity={0.5}
fontSize={11}
width={44}
tickFormatter={(v) => `${Number(v).toFixed(0)}%`}
domain={[40, 60]}
ticks={[40, 45, 50, 55, 60]}
/>
<Tooltip
contentStyle={{
background: "var(--color-bg)",
border: "1px solid var(--color-muted)",
borderRadius: 8,
fontSize: 12,
color: "var(--color-fg)",
}}
formatter={(value, name) => [
`${Number(value).toFixed(1)}%`,
name === "left" ? "Lewa" : "Prawa",
]}
labelFormatter={(l) => `${Number(l).toFixed(2)} km`}
/>
<ReferenceLine y={50} stroke="var(--color-fg)" strokeOpacity={0.3} strokeDasharray="4 4" />
<Area
type="monotone"
dataKey="left"
name="left"
stroke="var(--color-accent)"
strokeWidth={2}
fill={`url(#gcb-l-${uid})`}
dot={false}
/>
<Area
type="monotone"
dataKey="right"
name="right"
stroke="var(--color-sand)"
strokeWidth={2}
fill={`url(#gcb-r-${uid})`}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
import { useEffect, useState } from "react";
import {
Area,
AreaChart,
CartesianGrid,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useId } from "react";
type Props = {
data: { distanceKm: number; value: number }[];
label: string;
unit: string;
color?: string;
referenceLine?: number;
decimals?: number;
};
export function RunMetricChart({
data,
label,
unit,
color = "var(--color-accent)",
referenceLine,
decimals = 0,
}: Props) {
const uid = useId();
const gradId = `grad-${uid.replace(/:/g, "")}`;
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <div className="h-[160px] animate-pulse rounded-lg border border-muted/40 bg-surface" />;
}
const values = data.map((p) => p.value);
const min = Math.min(...values);
const max = Math.max(...values);
const pad = Math.max(1, Math.round((max - min) * 0.15));
const fmt = (v: number) =>
decimals > 0 ? `${Number(v).toFixed(decimals)} ${unit}` : `${Math.round(v)} ${unit}`;
return (
<div className="rounded-lg border border-muted/40 bg-surface p-4">
<div className="mb-2 text-sm text-fg/60">{label}</div>
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.25} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="var(--color-muted)" opacity={0.3} vertical={false} />
<XAxis
dataKey="distanceKm"
stroke="var(--color-fg)"
opacity={0.5}
fontSize={11}
tickFormatter={(v) => `${Number(v).toFixed(1)} km`}
interval={Math.max(0, Math.floor(data.length / 5) - 1)}
/>
<YAxis
stroke="var(--color-fg)"
opacity={0.5}
fontSize={11}
width={50}
tickFormatter={fmt}
domain={[min - pad, max + pad]}
/>
<Tooltip
contentStyle={{
background: "var(--color-bg)",
border: "1px solid var(--color-muted)",
borderRadius: 8,
fontSize: 12,
color: "var(--color-fg)",
}}
formatter={(value) => fmt(Number(value))}
labelFormatter={(l) => `${Number(l).toFixed(2)} km`}
/>
{referenceLine !== undefined && (
<ReferenceLine
y={referenceLine}
stroke="var(--color-fg)"
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
)}
<Area
type="monotone"
dataKey="value"
name={label}
stroke={color}
strokeWidth={2}
fill={`url(#${gradId})`}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { ObjectId } from "mongodb";
import { formatDate, formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format"; import { formatDate, formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format";
import { fetchRecentWellness, type DayWellness } from "@/lib/garmin/wellness"; import { fetchRecentWellness, type DayWellness } from "@/lib/garmin/wellness";
import { getAuthorizedClient } from "@/lib/garmin/client"; import { getAuthorizedClient } from "@/lib/garmin/client";
import { getRunningActivity, listRunningActivities, type RunningActivity } from "@/lib/models/running"; import { getRunningActivity, listRunningActivities, type RunMetrics, type RunningActivity } from "@/lib/models/running";
import { getStrengthWorkout, listStrengthWorkouts, type StrengthWorkout } from "@/lib/models/strength"; import { getStrengthWorkout, listStrengthWorkouts, type StrengthWorkout } from "@/lib/models/strength";
import { import {
getLatestAnalysisForTarget, getLatestAnalysisForTarget,
@@ -28,6 +28,50 @@ Jeśli podano dane z poprzednich treningów, odnieś się do progresu (np. zmian
type PreviousRun = { run: RunningActivity; analysis: AiAnalysis | null }; type PreviousRun = { run: RunningActivity; analysis: AiAnalysis | null };
type PreviousWorkout = { workout: StrengthWorkout; analysis: AiAnalysis | null }; type PreviousWorkout = { workout: StrengthWorkout; analysis: AiAnalysis | null };
function buildRunMetricsSummary(metrics: RunMetrics, totalDistanceM: number): string[] {
const { distanceKm, hrBpm, gcbLeftPct } = metrics;
if (!hrBpm && !gcbLeftPct) return [];
const n = distanceKm.length;
if (n < 8) return [];
const maxDist = Math.max(...distanceKm);
const totalKm = totalDistanceM / 1000;
const useIndex = maxDist === 0;
const position = (i: number) => (useIndex ? i / n : distanceKm[i] / maxDist);
const kmLabel = (i: number) =>
useIndex ? ((i / n) * totalKm).toFixed(1) : distanceKm[i].toFixed(1);
const avg = (vals: number[]) =>
vals.length ? Math.round(vals.reduce((s, v) => s + v, 0) / vals.length) : null;
const lines = [`Dane w trakcie biegu (4 kwartyle):`];
for (let q = 0; q < 4; q++) {
const from = q / 4;
const to = (q + 1) / 4;
const idx = Array.from({ length: n }, (_, i) => i).filter(
(i) => position(i) >= from && position(i) < to
);
if (idx.length === 0) continue;
const parts = [`${kmLabel(idx[0])}${kmLabel(idx[idx.length - 1])} km`];
if (hrBpm) {
const vals = idx.map((i) => hrBpm[i]).filter((v) => v > 0);
const a = avg(vals);
if (a !== null) parts.push(`HR śr. ${a} bpm`);
}
if (gcbLeftPct) {
const vals = idx.map((i) => gcbLeftPct[i]).filter((v) => v > 0);
if (vals.length > 0) {
const mean = vals.reduce((s, v) => s + v, 0) / vals.length;
parts.push(`balans L/P ${mean.toFixed(1)}%/${(100 - mean).toFixed(1)}%`);
}
}
lines.push(`- ${parts.join(", ")}`);
}
return lines.length > 1 ? lines : [];
}
function buildRunningPrompt(activity: RunningActivity, previousRuns: PreviousRun[]): string { function buildRunningPrompt(activity: RunningActivity, previousRuns: PreviousRun[]): string {
const lines = [ const lines = [
`Przeanalizuj poniższy bieg i podaj krótkie podsumowanie oraz wskazówki potreningowe.`, `Przeanalizuj poniższy bieg i podaj krótkie podsumowanie oraz wskazówki potreningowe.`,
@@ -58,6 +102,13 @@ function buildRunningPrompt(activity: RunningActivity, previousRuns: PreviousRun
if (activity.aerobicTrainingEffect) lines.push(`Efekt treningowy aerobowy: ${activity.aerobicTrainingEffect.toFixed(1)}`); if (activity.aerobicTrainingEffect) lines.push(`Efekt treningowy aerobowy: ${activity.aerobicTrainingEffect.toFixed(1)}`);
if (activity.anaerobicTrainingEffect) lines.push(`Efekt treningowy anaerobowy: ${activity.anaerobicTrainingEffect.toFixed(1)}`); if (activity.anaerobicTrainingEffect) lines.push(`Efekt treningowy anaerobowy: ${activity.anaerobicTrainingEffect.toFixed(1)}`);
if (activity.runMetrics) {
const metricLines = buildRunMetricsSummary(activity.runMetrics, activity.distanceM);
if (metricLines.length > 0) {
lines.push(``, ...metricLines);
}
}
if (previousRuns.length > 0) { if (previousRuns.length > 0) {
lines.push(``, `Poprzednie biegi (od najnowszego):`); lines.push(``, `Poprzednie biegi (od najnowszego):`);
for (const { run, analysis } of previousRuns) { for (const { run, analysis } of previousRuns) {

View File

@@ -1,7 +1,7 @@
import { GarminConnect } from "garmin-connect"; import { GarminConnect } from "garmin-connect";
import type { IActivity } from "garmin-connect/dist/garmin/types/activity"; import type { IActivity } from "garmin-connect/dist/garmin/types/activity";
import type { IOauth1Token } from "garmin-connect/dist/garmin/types"; import type { IOauth1Token } from "garmin-connect/dist/garmin/types";
import type { RoutePoint, RunningActivityInput } from "@/lib/models/running"; import type { RoutePoint, RunMetrics, RunningActivityInput } from "@/lib/models/running";
import { getSavedOauth1Token } from "@/lib/models/garmin-auth"; import { getSavedOauth1Token } from "@/lib/models/garmin-auth";
import { completeMfaAndGetTicket, loginAndGetTicket, type GarminPendingMfa } from "@/lib/garmin/sso"; import { completeMfaAndGetTicket, loginAndGetTicket, type GarminPendingMfa } from "@/lib/garmin/sso";
@@ -61,13 +61,14 @@ function mapActivity(activity: IActivity): RunningActivityInput {
const GC_API = "https://connectapi.garmin.com"; const GC_API = "https://connectapi.garmin.com";
const MAX_POLYLINE_POINTS = 500; const MAX_POLYLINE_POINTS = 500;
const MAX_ELEVATION_POINTS = 300;
type GarminPolylinePoint = { lat: number; lon: number; altitude?: number }; type GarminPolylinePoint = { lat: number; lon: number };
type GarminActivityDetailsResponse = { type GarminActivityDetailsResponse = {
geoPolylineDTO?: { polyline?: GarminPolylinePoint[] }; geoPolylineDTO?: { polyline?: GarminPolylinePoint[] };
}; };
export async function fetchActivityRoutePoints( async function fetchPolyline(
client: GarminConnect, client: GarminConnect,
garminActivityId: number garminActivityId: number
): Promise<RoutePoint[] | null> { ): Promise<RoutePoint[] | null> {
@@ -78,6 +79,108 @@ export async function fetchActivityRoutePoints(
return polyline.map((p) => [p.lat, p.lon] as RoutePoint); return polyline.map((p) => [p.lat, p.lon] as RoutePoint);
} }
type GpxPoint = { lat: number; lon: number; ele: number };
async function fetchGpxPoints(
client: GarminConnect,
garminActivityId: number
): Promise<GpxPoint[] | null> {
const gpxUrl = `${GC_API}/download-service/export/gpx/activity/${garminActivityId}`;
const gpxText = await client.get<string>(gpxUrl);
if (!gpxText || typeof gpxText !== "string") return null;
const trkptRe = /<trkpt\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>[\s\S]*?<ele>([^<]+)<\/ele>/g;
const points: GpxPoint[] = [];
let m: RegExpExecArray | null;
while ((m = trkptRe.exec(gpxText)) !== null) {
points.push({ lat: parseFloat(m[1]), lon: parseFloat(m[2]), ele: parseFloat(m[3]) });
}
return points.length > 0 ? points : null;
}
function downsample<T>(arr: T[], maxLen: number): T[] {
if (arr.length <= maxLen) return arr;
const step = arr.length / maxLen;
return Array.from({ length: maxLen }, (_, i) => arr[Math.round(i * step)]);
}
export async function fetchActivityRoutePoints(
client: GarminConnect,
garminActivityId: number
): Promise<{ points: RoutePoint[]; elevationProfile: number[] } | null> {
const [polylinePoints, gpxPoints] = await Promise.allSettled([
fetchPolyline(client, garminActivityId),
fetchGpxPoints(client, garminActivityId),
]);
const routePoints =
polylinePoints.status === "fulfilled" ? polylinePoints.value : null;
const gpx = gpxPoints.status === "fulfilled" ? gpxPoints.value : null;
if (!routePoints && !gpx) return null;
const points = routePoints ?? (gpx ? downsample(gpx.map((p) => [p.lat, p.lon] as RoutePoint), MAX_POLYLINE_POINTS) : []);
const elevationProfile = gpx
? downsample(gpx, MAX_ELEVATION_POINTS).map((p) => p.ele)
: (routePoints ? routePoints.map(() => 0) : []);
return { points, elevationProfile };
}
export async function fetchActivityRunMetrics(
client: GarminConnect,
garminActivityId: number
): Promise<RunMetrics | null> {
const url = `${GC_API}/activity-service/activity/${garminActivityId}/details`;
type Descriptor = { key: string; metricsIndex: number };
type Row = { metrics: (number | null)[] };
type Response = { metricDescriptors?: Descriptor[]; activityDetailMetrics?: Row[] };
const data = await client.get<Response>(url);
const descriptors = data?.metricDescriptors ?? [];
const rows = data?.activityDetailMetrics ?? [];
if (rows.length === 0) return null;
const idx: Record<string, number> = {};
for (const d of descriptors) idx[d.key] = d.metricsIndex;
const get = (row: Row, key: string): number | null => {
const i = idx[key];
return i !== undefined ? (row.metrics[i] ?? null) : null;
};
const MAX = 300;
const step = Math.max(1, Math.floor(rows.length / MAX));
const sampled = rows.filter((_, i) => i % step === 0 || i === rows.length - 1);
// Try several known Garmin distance metric keys
const distKey = ["directDistance", "sumDistance", "directCumulativeDistance"].find(
(k) => sampled.some((row) => get(row, k) !== null && get(row, k)! > 0)
);
const distanceKm = sampled.map((row) => {
const d = distKey ? get(row, distKey) : null;
return d !== null ? Math.round(d / 10) / 100 : 0;
});
const series = (key: string, decimals = 0): number[] | undefined => {
const values = sampled.map((row) => {
const v = get(row, key);
if (v === null) return 0;
return decimals > 0 ? Math.round(v * 10 ** decimals) / 10 ** decimals : Math.round(v);
});
return values.some((v) => v > 0) ? values : undefined;
};
return {
distanceKm,
hrBpm: series("directHeartRate"),
cadenceSpm: series("directDoubleCadence"),
gctMs: series("directGroundContactTime"),
gcbLeftPct: series("directGroundContactBalanceLeft", 1),
};
}
function getCredentials(): { username: string; password: string } { function getCredentials(): { username: string; password: string } {
const username = process.env.GARMIN_EMAIL; const username = process.env.GARMIN_EMAIL;
const password = process.env.GARMIN_PASSWORD; const password = process.env.GARMIN_PASSWORD;

View File

@@ -34,10 +34,20 @@ export type RunningActivityInput = z.infer<typeof runningActivitySchema>;
export type RoutePoint = [number, number]; export type RoutePoint = [number, number];
export type RunMetrics = {
distanceKm: number[];
hrBpm?: number[];
cadenceSpm?: number[];
gctMs?: number[];
gcbLeftPct?: number[];
};
export type RunningActivity = RunningActivityInput & { export type RunningActivity = RunningActivityInput & {
_id: ObjectId; _id: ObjectId;
createdAt: Date; createdAt: Date;
routePoints?: RoutePoint[]; routePoints?: RoutePoint[];
elevationProfile?: number[];
runMetrics?: RunMetrics;
}; };
const COLLECTION = "running_activities"; const COLLECTION = "running_activities";
@@ -72,12 +82,21 @@ export async function getRunningActivity(id: string): Promise<RunningActivity |
return collection.findOne({ _id: new ObjectId(id) }); return collection.findOne({ _id: new ObjectId(id) });
} }
export async function setRunningActivityRoutePoints( export async function setRunningActivityMetrics(
garminActivityId: number, garminActivityId: number,
points: RoutePoint[] metrics: RunMetrics
): Promise<void> { ): Promise<void> {
const collection = await getCollection(); const collection = await getCollection();
await collection.updateOne({ garminActivityId }, { $set: { routePoints: points } }); await collection.updateOne({ garminActivityId }, { $set: { runMetrics: metrics } });
}
export async function setRunningActivityRoutePoints(
garminActivityId: number,
points: RoutePoint[],
elevationProfile: number[]
): Promise<void> {
const collection = await getCollection();
await collection.updateOne({ garminActivityId }, { $set: { routePoints: points, elevationProfile } });
} }
type SyncState = { _id: "garmin"; lastSyncAt: Date }; type SyncState = { _id: "garmin"; lastSyncAt: Date };