Redesign the Review Queue pace/HR chart: always show actuals, time-based X-axis, area style, phase-colored bands
Previously the chart rendered nothing at all unless a structured Garmin workout's target was resolved, which meant most activities showed no chart. Now it always shows actual pace/HR over elapsed time (in minutes) for any activity with laps, filled as an area rather than a line, with the expected band layered on top only when available. Each lap's warm-up/effort/recovery/ cool-down phase (from Garmin's per-lap IntensityType) shades the background, with a small legend for whichever phases are present.
This commit is contained in:
@@ -240,6 +240,28 @@ button:disabled {
|
|||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.phase-legend {
|
||||||
|
flex-basis: 100%;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #9aa0ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-legend-swatch {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
.kinds-table {
|
.kinds-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Area, AreaChart, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
import type { Lap } from "../../types/api";
|
import type { Lap } from "../../types/api";
|
||||||
|
|
||||||
function paceSecPerKm(mps: number | null): number | null {
|
function paceSecPerKm(mps: number | null): number | null {
|
||||||
@@ -16,6 +16,12 @@ function formatPace(secPerKm: number): string {
|
|||||||
return `${formatPaceShort(secPerKm)}/km`;
|
return `${formatPaceShort(secPerKm)}/km`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatElapsed(minutes: number): string {
|
||||||
|
const m = Math.floor(minutes);
|
||||||
|
const s = Math.round((minutes - m) * 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Recharts' "dataMin - N" domain expressions don't combine reliably with
|
// Recharts' "dataMin - N" domain expressions don't combine reliably with
|
||||||
// reversed axes, so the padded numeric domain is computed directly instead.
|
// reversed axes, so the padded numeric domain is computed directly instead.
|
||||||
function paddedDomain(values: Array<number | null | undefined>, pad: number): [number, number] {
|
function paddedDomain(values: Array<number | null | undefined>, pad: number): [number, number] {
|
||||||
@@ -24,72 +30,130 @@ function paddedDomain(values: Array<number | null | undefined>, pad: number): [n
|
|||||||
return [Math.min(...nums) - pad, Math.max(...nums) + pad];
|
return [Math.min(...nums) - pad, Math.max(...nums) + pad];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shows each lap's actual pace/HR against the expected band from the
|
// Phase a lap represents, by Garmin's own per-lap IntensityType tagging.
|
||||||
// activity's structured Garmin workout (see backend's alignWorkoutTargets).
|
// Colors distinguish warm-up / effort / recovery / cool-down bands behind
|
||||||
// Renders nothing if no lap has a resolved target -- most activities aren't
|
// the pace/HR trace so a run's structure reads at a glance.
|
||||||
// from a structured workout, and an empty chart isn't useful.
|
const PHASE_COLORS: Record<string, string> = {
|
||||||
|
WARMUP: "#f59e0b",
|
||||||
|
ACTIVE: "#ef4444",
|
||||||
|
REST: "#3b82f6",
|
||||||
|
RECOVERY: "#3b82f6",
|
||||||
|
COOLDOWN: "#a855f7",
|
||||||
|
};
|
||||||
|
const PHASE_LABELS: Record<string, string> = {
|
||||||
|
WARMUP: "Warm-up",
|
||||||
|
ACTIVE: "Effort",
|
||||||
|
REST: "Recovery",
|
||||||
|
RECOVERY: "Recovery",
|
||||||
|
COOLDOWN: "Cool-down",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Point {
|
||||||
|
t: number;
|
||||||
|
actualPace: number | null;
|
||||||
|
targetPaceRange: [number, number] | null;
|
||||||
|
actualHR: number | null;
|
||||||
|
targetHRRange: [number, number] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeTooltipFormatter(rangeLabel: string, formatValue: (v: number) => string) {
|
||||||
|
return (value: unknown, name: unknown): [string, string] => {
|
||||||
|
if (Array.isArray(value)) return [`${formatValue(value[0])}–${formatValue(value[1])}`, rangeLabel];
|
||||||
|
return [formatValue(Number(value)), String(name)];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows each lap's actual pace/HR over elapsed time for every activity that
|
||||||
|
// has laps, with the workout's warm-up/effort/recovery/cool-down structure
|
||||||
|
// shaded behind it. The expected band from a structured Garmin workout (see
|
||||||
|
// backend's alignWorkoutTargets) is layered on top when available -- most
|
||||||
|
// activities aren't from a structured workout with target zones, so absence
|
||||||
|
// of that band is the common case, not an error.
|
||||||
export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
|
export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
|
||||||
|
if (laps.length === 0) return null;
|
||||||
|
|
||||||
const hasPaceTarget = laps.some((l) => l.TargetPaceLowMps != null && l.TargetPaceHighMps != null);
|
const hasPaceTarget = laps.some((l) => l.TargetPaceLowMps != null && l.TargetPaceHighMps != null);
|
||||||
const hasHRTarget = laps.some((l) => l.TargetHRLowBpm != null && l.TargetHRHighBpm != null);
|
const hasHRTarget = laps.some((l) => l.TargetHRLowBpm != null && l.TargetHRHighBpm != null);
|
||||||
if (!hasPaceTarget && !hasHRTarget) return null;
|
|
||||||
|
|
||||||
const data = laps.map((l, i) => ({
|
const points: Point[] = [];
|
||||||
lap: i + 1,
|
const phaseBands: Array<{ x1: number; x2: number; color: string }> = [];
|
||||||
actualPace: paceSecPerKm(l.AvgSpeedMps),
|
let elapsedMin = 0;
|
||||||
|
for (const l of laps) {
|
||||||
|
const start = elapsedMin;
|
||||||
|
elapsedMin += l.DurationSeconds / 60;
|
||||||
|
|
||||||
// Pace (sec/km) is inverted vs speed (m/s): the *faster* (higher) speed
|
// Pace (sec/km) is inverted vs speed (m/s): the *faster* (higher) speed
|
||||||
// bound is the *lower* (faster) pace bound.
|
// bound is the *lower* (faster) pace bound.
|
||||||
targetPaceLow: l.TargetPaceHighMps != null ? paceSecPerKm(l.TargetPaceHighMps) : null,
|
const paceLow = l.TargetPaceHighMps != null ? paceSecPerKm(l.TargetPaceHighMps) : null;
|
||||||
targetPaceHigh: l.TargetPaceLowMps != null ? paceSecPerKm(l.TargetPaceLowMps) : null,
|
const paceHigh = l.TargetPaceLowMps != null ? paceSecPerKm(l.TargetPaceLowMps) : null;
|
||||||
actualHR: l.AvgHR,
|
|
||||||
targetHRLow: l.TargetHRLowBpm,
|
|
||||||
targetHRHigh: l.TargetHRHighBpm,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const paceDomain = paddedDomain(data.flatMap((d) => [d.actualPace, d.targetPaceLow, d.targetPaceHigh]), 10);
|
points.push({
|
||||||
const hrDomain = paddedDomain(data.flatMap((d) => [d.actualHR, d.targetHRLow, d.targetHRHigh]), 5);
|
t: start,
|
||||||
|
actualPace: paceSecPerKm(l.AvgSpeedMps),
|
||||||
|
targetPaceRange: paceLow != null && paceHigh != null ? [paceLow, paceHigh] : null,
|
||||||
|
actualHR: l.AvgHR,
|
||||||
|
targetHRRange: l.TargetHRLowBpm != null && l.TargetHRHighBpm != null ? [l.TargetHRLowBpm, l.TargetHRHighBpm] : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const color = PHASE_COLORS[l.IntensityType];
|
||||||
|
if (color) phaseBands.push({ x1: start, x2: elapsedMin, color });
|
||||||
|
}
|
||||||
|
// Closing point so the last lap's step extends visually to the end.
|
||||||
|
points.push({ ...points[points.length - 1], t: elapsedMin });
|
||||||
|
|
||||||
|
const paceDomain = paddedDomain(points.flatMap((p) => [p.actualPace, ...(p.targetPaceRange ?? [])]), 10);
|
||||||
|
const hrDomain = paddedDomain(points.flatMap((p) => [p.actualHR, ...(p.targetHRRange ?? [])]), 5);
|
||||||
|
const phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => PHASE_COLORS[t]))];
|
||||||
|
|
||||||
|
const paceTooltipFormatter = rangeTooltipFormatter("Target range", (v) => `${formatPace(v)}`);
|
||||||
|
const hrTooltipFormatter = rangeTooltipFormatter("Target range", (v) => `${Math.round(v)} bpm`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="expected-actual-charts">
|
<div className="expected-actual-charts">
|
||||||
{hasPaceTarget && (
|
{phasesPresent.length > 0 && (
|
||||||
<div className="mini-chart">
|
<div className="phase-legend">
|
||||||
<span className="mini-chart-label">Pace vs target</span>
|
{phasesPresent.map((phase) => (
|
||||||
<ResponsiveContainer width="100%" height={90}>
|
<span key={phase} className="phase-legend-item">
|
||||||
<LineChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
|
<span className="phase-legend-swatch" style={{ background: PHASE_COLORS[phase] }} />
|
||||||
<XAxis dataKey="lap" tick={{ fontSize: 10 }} />
|
{PHASE_LABELS[phase] ?? phase}
|
||||||
<YAxis
|
</span>
|
||||||
reversed
|
))}
|
||||||
domain={paceDomain}
|
|
||||||
tick={{ fontSize: 10 }}
|
|
||||||
width={34}
|
|
||||||
tickFormatter={(v) => formatPaceShort(Number(v))}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(v) => formatPace(Number(v))}
|
|
||||||
labelFormatter={(l) => `Lap ${l}`}
|
|
||||||
contentStyle={{ fontSize: 12 }}
|
|
||||||
/>
|
|
||||||
<Line type="stepAfter" dataKey="targetPaceLow" stroke="#9aa0ab" strokeDasharray="3 3" dot={false} isAnimationActive={false} name="Target low" />
|
|
||||||
<Line type="stepAfter" dataKey="targetPaceHigh" stroke="#9aa0ab" strokeDasharray="3 3" dot={false} isAnimationActive={false} name="Target high" />
|
|
||||||
<Line type="monotone" dataKey="actualPace" stroke="#3b82f6" strokeWidth={2} dot={{ r: 2 }} isAnimationActive={false} name="Actual" />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasHRTarget && (
|
|
||||||
<div className="mini-chart">
|
|
||||||
<span className="mini-chart-label">HR vs target</span>
|
|
||||||
<ResponsiveContainer width="100%" height={90}>
|
|
||||||
<LineChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
|
|
||||||
<XAxis dataKey="lap" tick={{ fontSize: 10 }} />
|
|
||||||
<YAxis domain={hrDomain} tick={{ fontSize: 10 }} width={30} tickFormatter={(v) => String(Math.round(Number(v)))} />
|
|
||||||
<Tooltip formatter={(v) => `${Math.round(Number(v))} bpm`} labelFormatter={(l) => `Lap ${l}`} contentStyle={{ fontSize: 12 }} />
|
|
||||||
<Line type="stepAfter" dataKey="targetHRLow" stroke="#9aa0ab" strokeDasharray="3 3" dot={false} isAnimationActive={false} name="Target low" />
|
|
||||||
<Line type="stepAfter" dataKey="targetHRHigh" stroke="#9aa0ab" strokeDasharray="3 3" dot={false} isAnimationActive={false} name="Target high" />
|
|
||||||
<Line type="monotone" dataKey="actualHR" stroke="#ef4444" strokeWidth={2} dot={{ r: 2 }} isAnimationActive={false} name="Actual" />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="mini-chart">
|
||||||
|
<span className="mini-chart-label">Pace{hasPaceTarget ? " vs target" : ""}</span>
|
||||||
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
|
<AreaChart data={points} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
|
||||||
|
{phaseBands.map((b, i) => (
|
||||||
|
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={b.color} fillOpacity={0.12} strokeOpacity={0} />
|
||||||
|
))}
|
||||||
|
<XAxis dataKey="t" type="number" domain={[0, elapsedMin]} tick={{ fontSize: 10 }} tickFormatter={(v) => `${Math.round(Number(v))}m`} />
|
||||||
|
<YAxis reversed domain={paceDomain} tick={{ fontSize: 10 }} width={34} tickFormatter={(v) => formatPaceShort(Number(v))} />
|
||||||
|
<Tooltip formatter={paceTooltipFormatter} labelFormatter={(t) => `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} />
|
||||||
|
{hasPaceTarget && (
|
||||||
|
<Area type="stepAfter" dataKey="targetPaceRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
||||||
|
)}
|
||||||
|
<Area type="monotone" dataKey="actualPace" stroke="#3b82f6" strokeWidth={2} fill="#3b82f6" fillOpacity={0.3} dot={{ r: 2 }} isAnimationActive={false} name="Actual" connectNulls />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="mini-chart">
|
||||||
|
<span className="mini-chart-label">HR{hasHRTarget ? " vs target" : ""}</span>
|
||||||
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
|
<AreaChart data={points} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
|
||||||
|
{phaseBands.map((b, i) => (
|
||||||
|
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={b.color} fillOpacity={0.12} strokeOpacity={0} />
|
||||||
|
))}
|
||||||
|
<XAxis dataKey="t" type="number" domain={[0, elapsedMin]} tick={{ fontSize: 10 }} tickFormatter={(v) => `${Math.round(Number(v))}m`} />
|
||||||
|
<YAxis domain={hrDomain} tick={{ fontSize: 10 }} width={30} tickFormatter={(v) => String(Math.round(Number(v)))} />
|
||||||
|
<Tooltip formatter={hrTooltipFormatter} labelFormatter={(t) => `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} />
|
||||||
|
{hasHRTarget && (
|
||||||
|
<Area type="stepAfter" dataKey="targetHRRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
||||||
|
)}
|
||||||
|
<Area type="monotone" dataKey="actualHR" stroke="#ef4444" strokeWidth={2} fill="#ef4444" fillOpacity={0.3} dot={{ r: 2 }} isAnimationActive={false} name="Actual" connectNulls />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user