init
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
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 { 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 { getLatestAnalysisForTarget, serializeAnalysis } from "@/lib/models/analysis";
|
||||||
import {
|
import {
|
||||||
getRunningActivity,
|
getRunningActivity,
|
||||||
type RunMetrics,
|
|
||||||
type RunningActivity,
|
type RunningActivity,
|
||||||
} from "@/lib/models/running";
|
} from "@/lib/models/running";
|
||||||
import { getCurrentUserId } from "@/lib/session";
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 }) {
|
function RouteMap({ activity }: { activity: RunningActivity }) {
|
||||||
const routePoints = activity.routePoints;
|
const routePoints = activity.routePoints;
|
||||||
return (
|
return (
|
||||||
@@ -32,77 +52,71 @@ function RouteMap({ activity }: { activity: RunningActivity }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ElevationSection({ activity }: { activity: RunningActivity }) {
|
function buildChartData(activity: RunningActivity) {
|
||||||
const elevationProfile = activity.elevationProfile;
|
const maxDistKm = Math.round(activity.distanceM / 10) / 100;
|
||||||
if (!elevationProfile || elevationProfile.length < 2) return null;
|
const grid = buildGrid(maxDistKm);
|
||||||
|
|
||||||
const elevData = elevationProfile
|
const elevProfile = activity.elevationProfile;
|
||||||
.map((altM, i) => ({
|
const metrics = activity.runMetrics;
|
||||||
distanceKm: Math.round((i / elevationProfile.length) * activity.distanceM / 10) / 100,
|
|
||||||
altM,
|
|
||||||
}))
|
|
||||||
.filter((p) => p.altM > 0);
|
|
||||||
|
|
||||||
if (elevData.length < 2) return null;
|
const elevSorted = elevProfile
|
||||||
|
? elevProfile
|
||||||
const paceSrc = activity.runMetrics?.paceSec;
|
.map((altM, i) => ({ dist: (i / elevProfile.length) * maxDistKm, value: altM }))
|
||||||
const data = elevData.map((ep, i) => {
|
.filter((p) => p.value > 0)
|
||||||
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} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<typeof p> => p !== null)
|
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
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 (
|
const hrSorted = metrics?.hrBpm
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
? metricsDistKm
|
||||||
{hrData.length > 1 && (
|
.map((d, i) => ({ dist: d, value: metrics.hrBpm![i] ?? 0 }))
|
||||||
<RunMetricChart data={hrData} label="Tętno" unit="bpm" color="var(--color-accent)" />
|
.filter((p) => p.value > 0)
|
||||||
)}
|
: [];
|
||||||
{gcbData.length > 1 && <GcbChart data={gcbData} />}
|
|
||||||
</div>
|
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 analysis = await getLatestAnalysisForTarget(userId, "running", activity._id);
|
||||||
|
const { elevData, hrData, gcbData } = buildChartData(activity);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -173,8 +188,15 @@ export default async function RunningActivityPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ElevationSection activity={activity} />
|
{elevData && <ElevationChart data={elevData} syncId="run-detail" />}
|
||||||
<RunMetricsSection activity={activity} />
|
{(hrData || gcbData) && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{hrData && (
|
||||||
|
<RunMetricChart data={hrData} label="Tętno" unit="bpm" color="var(--color-accent)" syncId="run-detail" />
|
||||||
|
)}
|
||||||
|
{gcbData && <GcbChart data={gcbData} syncId="run-detail" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<AiAnalysisCard
|
<AiAnalysisCard
|
||||||
targetType="running"
|
targetType="running"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type Point = { distanceKm: number; altM: number; paceSec?: number };
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: Point[];
|
data: Point[];
|
||||||
|
syncId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function fmtPace(sec: number): string {
|
function fmtPace(sec: number): string {
|
||||||
@@ -24,7 +25,7 @@ function fmtPace(sec: number): string {
|
|||||||
return `${m}:${s.toString().padStart(2, "0")} /km`;
|
return `${m}:${s.toString().padStart(2, "0")} /km`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ElevationChart({ data }: Props) {
|
export function ElevationChart({ data, syncId }: Props) {
|
||||||
const uid = useId();
|
const uid = useId();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
@@ -64,7 +65,7 @@ export function ElevationChart({ data }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={110}>
|
<ResponsiveContainer width="100%" height={110}>
|
||||||
<ComposedChart data={data} margin={{ top: 4, right: hasPace ? 52 : 8, left: 0, bottom: 0 }}>
|
<ComposedChart syncId={syncId} data={data} margin={{ top: 4, right: hasPace ? 52 : 8, left: 0, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`elev-${uid}`} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id={`elev-${uid}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="var(--color-accent)" stopOpacity={0.25} />
|
<stop offset="5%" stopColor="var(--color-accent)" stopOpacity={0.25} />
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ type GcbPoint = { distanceKm: number; left: number; right: number };
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: GcbPoint[];
|
data: GcbPoint[];
|
||||||
|
syncId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GcbChart({ data }: Props) {
|
export function GcbChart({ data, syncId }: Props) {
|
||||||
const uid = useId();
|
const uid = useId();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
@@ -41,7 +42,7 @@ export function GcbChart({ data }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={120}>
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
<AreaChart syncId={syncId} data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`gcb-l-${uid}`} x1="0" y1="0" x2="0" y2="1">
|
<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="5%" stopColor="var(--color-accent)" stopOpacity={0.2} />
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Props = {
|
|||||||
decimals?: number;
|
decimals?: number;
|
||||||
format?: "pace";
|
format?: "pace";
|
||||||
reversed?: boolean;
|
reversed?: boolean;
|
||||||
|
syncId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RunMetricChart({
|
export function RunMetricChart({
|
||||||
@@ -33,6 +34,7 @@ export function RunMetricChart({
|
|||||||
decimals = 0,
|
decimals = 0,
|
||||||
format,
|
format,
|
||||||
reversed = false,
|
reversed = false,
|
||||||
|
syncId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const uid = useId();
|
const uid = useId();
|
||||||
const gradId = `grad-${uid.replace(/:/g, "")}`;
|
const gradId = `grad-${uid.replace(/:/g, "")}`;
|
||||||
@@ -60,7 +62,7 @@ export function RunMetricChart({
|
|||||||
<div className="rounded-lg border border-muted/40 bg-surface p-4">
|
<div className="rounded-lg border border-muted/40 bg-surface p-4">
|
||||||
<div className="mb-2 text-sm text-fg/60">{label}</div>
|
<div className="mb-2 text-sm text-fg/60">{label}</div>
|
||||||
<ResponsiveContainer width="100%" height={120}>
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
<AreaChart syncId={syncId} data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={color} stopOpacity={0.25} />
|
<stop offset="5%" stopColor={color} stopOpacity={0.25} />
|
||||||
|
|||||||
Reference in New Issue
Block a user