diff --git a/.idea/db-forest-config.xml b/.idea/db-forest-config.xml new file mode 100644 index 0000000..612e11a --- /dev/null +++ b/.idea/db-forest-config.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/running/[id]/page.tsx b/app/running/[id]/page.tsx index 016c4f4..1ecd352 100644 --- a/app/running/[id]/page.tsx +++ b/app/running/[id]/page.tsx @@ -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 ; +} + +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 => p !== null) + : []; + + if (!hrData.length && !gcbData.length) return null; + + return ( +
+ {hrData.length > 1 && ( + + )} + {gcbData.length > 1 && } +
+ ); +} + function MapSkeleton() { return (
@@ -67,22 +191,18 @@ export default async function RunningActivityPage({
- {/* Map: cols 1–2, rows 1–3 — streamed in after page skeleton */} }> - {/* Col 3, rows 1–3: key pace stats */} - {/* Row 4: HR, calories, cadence — always shown */} - {/* Row 5+: optional advanced stats, auto-flow */} {activity.maxHr ? : null} {activity.elevationGainM ? : null} {activity.vo2Max ? : null} @@ -117,6 +237,14 @@ export default async function RunningActivityPage({ ) : null}
+ + + + + + + + ); diff --git a/app/running/actions.ts b/app/running/actions.ts index bee3642..72c6d4c 100644 --- a/app/running/actions.ts +++ b/app/running/actions.ts @@ -97,9 +97,9 @@ export async function loadActivityRoute(activityMongoId: string): Promise setMounted(true), []); + + if (!mounted) { + return
; + } + + 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 ( +
+
Profil wysokości
+ + + + + + + + + + `${Number(v).toFixed(1)} km`} + interval={Math.max(0, Math.floor(data.length / 5) - 1)} + /> + `${Math.round(v)} m`} + domain={[minAlt - pad, maxAlt + pad]} + /> + [`${Math.round(Number(value))} m n.p.m.`, "Wysokość"]} + labelFormatter={(label) => `${Number(label).toFixed(2)} km`} + /> + + + +
+ ); +} diff --git a/components/gcb-chart.tsx b/components/gcb-chart.tsx new file mode 100644 index 0000000..ad77ec6 --- /dev/null +++ b/components/gcb-chart.tsx @@ -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
; + } + + return ( +
+
+ Balans kontaktu + + + Lewa + + + + Prawa + +
+ + + + + + + + + + + + + + `${Number(v).toFixed(1)} km`} + interval={Math.max(0, Math.floor(data.length / 5) - 1)} + /> + `${Number(v).toFixed(0)}%`} + domain={[40, 60]} + ticks={[40, 45, 50, 55, 60]} + /> + [ + `${Number(value).toFixed(1)}%`, + name === "left" ? "Lewa" : "Prawa", + ]} + labelFormatter={(l) => `${Number(l).toFixed(2)} km`} + /> + + + + + +
+ ); +} diff --git a/components/run-metric-chart.tsx b/components/run-metric-chart.tsx new file mode 100644 index 0000000..e0c1b00 --- /dev/null +++ b/components/run-metric-chart.tsx @@ -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
; + } + + 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 ( +
+
{label}
+ + + + + + + + + + `${Number(v).toFixed(1)} km`} + interval={Math.max(0, Math.floor(data.length / 5) - 1)} + /> + + fmt(Number(value))} + labelFormatter={(l) => `${Number(l).toFixed(2)} km`} + /> + {referenceLine !== undefined && ( + + )} + + + +
+ ); +} diff --git a/lib/ai/claude.ts b/lib/ai/claude.ts index e6dc32f..e183789 100644 --- a/lib/ai/claude.ts +++ b/lib/ai/claude.ts @@ -3,7 +3,7 @@ import { ObjectId } from "mongodb"; import { formatDate, formatDateShort, formatDistance, formatDuration, formatPace } from "@/lib/format"; import { fetchRecentWellness, type DayWellness } from "@/lib/garmin/wellness"; 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 { 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 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 { const lines = [ `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.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) { lines.push(``, `Poprzednie biegi (od najnowszego):`); for (const { run, analysis } of previousRuns) { diff --git a/lib/garmin/client.ts b/lib/garmin/client.ts index e75fb30..c44462f 100644 --- a/lib/garmin/client.ts +++ b/lib/garmin/client.ts @@ -1,7 +1,7 @@ import { GarminConnect } from "garmin-connect"; import type { IActivity } from "garmin-connect/dist/garmin/types/activity"; 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 { 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 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 = { geoPolylineDTO?: { polyline?: GarminPolylinePoint[] }; }; -export async function fetchActivityRoutePoints( +async function fetchPolyline( client: GarminConnect, garminActivityId: number ): Promise { @@ -78,6 +79,108 @@ export async function fetchActivityRoutePoints( 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 { + const gpxUrl = `${GC_API}/download-service/export/gpx/activity/${garminActivityId}`; + const gpxText = await client.get(gpxUrl); + if (!gpxText || typeof gpxText !== "string") return null; + + const trkptRe = /]*>[\s\S]*?([^<]+)<\/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(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 { + 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(url); + const descriptors = data?.metricDescriptors ?? []; + const rows = data?.activityDetailMetrics ?? []; + if (rows.length === 0) return null; + + const idx: Record = {}; + 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 } { const username = process.env.GARMIN_EMAIL; const password = process.env.GARMIN_PASSWORD; diff --git a/lib/models/running.ts b/lib/models/running.ts index eb6f71d..2a33042 100644 --- a/lib/models/running.ts +++ b/lib/models/running.ts @@ -34,10 +34,20 @@ export type RunningActivityInput = z.infer; export type RoutePoint = [number, number]; +export type RunMetrics = { + distanceKm: number[]; + hrBpm?: number[]; + cadenceSpm?: number[]; + gctMs?: number[]; + gcbLeftPct?: number[]; +}; + export type RunningActivity = RunningActivityInput & { _id: ObjectId; createdAt: Date; routePoints?: RoutePoint[]; + elevationProfile?: number[]; + runMetrics?: RunMetrics; }; const COLLECTION = "running_activities"; @@ -72,12 +82,21 @@ export async function getRunningActivity(id: string): Promise { 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 { + const collection = await getCollection(); + await collection.updateOne({ garminActivityId }, { $set: { routePoints: points, elevationProfile } }); } type SyncState = { _id: "garmin"; lastSyncAt: Date };