From bf8624c95403a9fe06eae5fa2961e4f39df99c58 Mon Sep 17 00:00:00 2001 From: Dominik Klarkowski Date: Thu, 18 Jun 2026 12:23:05 +0200 Subject: [PATCH] init --- app/running/[id]/page.tsx | 162 ++++++++++++++++++-------------- components/elevation-chart.tsx | 5 +- components/gcb-chart.tsx | 5 +- components/run-metric-chart.tsx | 4 +- 4 files changed, 101 insertions(+), 75 deletions(-) diff --git a/app/running/[id]/page.tsx b/app/running/[id]/page.tsx index 5cab6d8..f1420f9 100644 --- a/app/running/[id]/page.tsx +++ b/app/running/[id]/page.tsx @@ -1,4 +1,3 @@ -import { Suspense } from "react"; import { notFound } from "next/navigation"; import { AiAnalysisCard } from "@/components/ai-analysis-card"; import { ElevationChart } from "@/components/elevation-chart"; @@ -10,13 +9,34 @@ import { formatDate, formatDistance, formatDuration, formatPace } from "@/lib/fo import { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis"; import { getRunningActivity, - type RunMetrics, type RunningActivity, } from "@/lib/models/running"; import { getCurrentUserId } from "@/lib/session"; export const dynamic = "force-dynamic"; +const CHART_SAMPLES = 200; + +function interpolate(sorted: { dist: number; value: number }[], target: number): number | null { + if (sorted.length === 0) return null; + if (target <= sorted[0].dist) return sorted[0].value; + if (target >= sorted[sorted.length - 1].dist) return sorted[sorted.length - 1].value; + let lo = 0; + let hi = sorted.length - 1; + while (lo + 1 < hi) { + const mid = (lo + hi) >> 1; + if (sorted[mid].dist <= target) lo = mid; else hi = mid; + } + const t = (target - sorted[lo].dist) / (sorted[hi].dist - sorted[lo].dist); + return sorted[lo].value + t * (sorted[hi].value - sorted[lo].value); +} + +function buildGrid(maxDistKm: number): number[] { + return Array.from({ length: CHART_SAMPLES }, (_, i) => + Math.round((i / (CHART_SAMPLES - 1)) * maxDistKm * 100) / 100 + ); +} + function RouteMap({ activity }: { activity: RunningActivity }) { const routePoints = activity.routePoints; return ( @@ -32,77 +52,71 @@ function RouteMap({ activity }: { activity: RunningActivity }) { ); } -function ElevationSection({ activity }: { activity: RunningActivity }) { - const elevationProfile = activity.elevationProfile; - if (!elevationProfile || elevationProfile.length < 2) return null; +function buildChartData(activity: RunningActivity) { + const maxDistKm = Math.round(activity.distanceM / 10) / 100; + const grid = buildGrid(maxDistKm); - const elevData = elevationProfile - .map((altM, i) => ({ - distanceKm: Math.round((i / elevationProfile.length) * activity.distanceM / 10) / 100, - altM, - })) - .filter((p) => p.altM > 0); + const elevProfile = activity.elevationProfile; + const metrics = activity.runMetrics; - if (elevData.length < 2) return null; - - 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 ; -} - -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); -} - -function RunMetricsSection({ activity }: { activity: RunningActivity }) { - const metrics: RunMetrics | undefined = activity.runMetrics; - - if (!metrics || metrics.distanceKm.length === 0) return null; - - const { hrBpm, gcbLeftPct } = metrics; - - 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) + const elevSorted = elevProfile + ? elevProfile + .map((altM, i) => ({ dist: (i / elevProfile.length) * maxDistKm, value: altM })) + .filter((p) => p.value > 0) : []; - if (!hrData.length && !gcbData.length) return null; + const metricsDistKm = metrics + ? (() => { + const raw = metrics.distanceKm; + const max = Math.max(...raw); + return max > 0 + ? raw + : raw.map((_, i) => Math.round(((i / (raw.length - 1)) * maxDistKm) * 100) / 100); + })() + : []; - return ( -
- {hrData.length > 1 && ( - - )} - {gcbData.length > 1 && } -
- ); + const hrSorted = metrics?.hrBpm + ? metricsDistKm + .map((d, i) => ({ dist: d, value: metrics.hrBpm![i] ?? 0 })) + .filter((p) => p.value > 0) + : []; + + const paceSorted = metrics?.paceSec + ? metricsDistKm + .map((d, i) => ({ dist: d, value: metrics.paceSec![i] ?? 0 })) + .filter((p) => p.value > 0 && p.value < 1800) + : []; + + const gcbSorted = metrics?.gcbLeftPct + ? metricsDistKm + .map((d, i) => ({ dist: d, value: metrics.gcbLeftPct![i] ?? 0 })) + .filter((p) => p.value > 0) + : []; + + const hasElev = elevSorted.length >= 2; + const hasHr = hrSorted.length >= 2; + const hasGcb = gcbSorted.length >= 2; + + const elevData = hasElev + ? grid.map((distanceKm) => { + const altM = interpolate(elevSorted, distanceKm) ?? 0; + const paceSec = paceSorted.length >= 2 ? interpolate(paceSorted, distanceKm) ?? undefined : undefined; + return { distanceKm, altM, paceSec }; + }) + : null; + + const hrData = hasHr + ? grid.map((distanceKm) => ({ distanceKm, value: interpolate(hrSorted, distanceKm) ?? 0 })) + : null; + + const gcbData = hasGcb + ? grid.map((distanceKm) => { + const left = Math.round((interpolate(gcbSorted, distanceKm) ?? 50) * 10) / 10; + return { distanceKm, left, right: Math.round((100 - left) * 10) / 10 }; + }) + : null; + + return { elevData, hrData, gcbData }; } @@ -120,6 +134,7 @@ export default async function RunningActivityPage({ } const analysis = await getLatestAnalysisForTarget(userId, "running", activity._id); + const { elevData, hrData, gcbData } = buildChartData(activity); return (
@@ -173,8 +188,15 @@ export default async function RunningActivityPage({ ) : null} - - + {elevData && } + {(hrData || gcbData) && ( +
+ {hrData && ( + + )} + {gcbData && } +
+ )} setMounted(true), []); @@ -64,7 +65,7 @@ export function ElevationChart({ data }: Props) { )}
- + diff --git a/components/gcb-chart.tsx b/components/gcb-chart.tsx index ad77ec6..775bcb0 100644 --- a/components/gcb-chart.tsx +++ b/components/gcb-chart.tsx @@ -16,9 +16,10 @@ type GcbPoint = { distanceKm: number; left: number; right: number }; type Props = { data: GcbPoint[]; + syncId?: string; }; -export function GcbChart({ data }: Props) { +export function GcbChart({ data, syncId }: Props) { const uid = useId(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); @@ -41,7 +42,7 @@ export function GcbChart({ data }: Props) { - + diff --git a/components/run-metric-chart.tsx b/components/run-metric-chart.tsx index 220ff87..3a6cf1c 100644 --- a/components/run-metric-chart.tsx +++ b/components/run-metric-chart.tsx @@ -22,6 +22,7 @@ type Props = { decimals?: number; format?: "pace"; reversed?: boolean; + syncId?: string; }; export function RunMetricChart({ @@ -33,6 +34,7 @@ export function RunMetricChart({ decimals = 0, format, reversed = false, + syncId, }: Props) { const uid = useId(); const gradId = `grad-${uid.replace(/:/g, "")}`; @@ -60,7 +62,7 @@ export function RunMetricChart({
{label}
- +