Show expected vs actual pace/HR per lap in the Review Queue
Activities recorded from a structured Garmin workout carry a workoutId; when its steps line up 1:1 with the recorded laps, the per-step target pace/HR zone is resolved (via a Karvonen lookup for named zones) and stored on each lap. The Review Queue plots it against the actual per-lap pace/HR so the user can eyeball whether a run matches its plan while sorting it.
This commit is contained in:
@@ -210,6 +210,25 @@ button:disabled {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.expected-actual-charts {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.mini-chart {
|
||||
flex: 1 1 220px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.mini-chart-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #9aa0ab;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.kinds-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
95
frontend/src/components/charts/ExpectedVsActualChart.tsx
Normal file
95
frontend/src/components/charts/ExpectedVsActualChart.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import type { Lap } from "../../types/api";
|
||||
|
||||
function paceSecPerKm(mps: number | null): number | null {
|
||||
if (mps == null || mps <= 0) return null;
|
||||
return 1000 / mps;
|
||||
}
|
||||
|
||||
function formatPaceShort(secPerKm: number): string {
|
||||
const m = Math.floor(secPerKm / 60);
|
||||
const s = Math.round(secPerKm % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatPace(secPerKm: number): string {
|
||||
return `${formatPaceShort(secPerKm)}/km`;
|
||||
}
|
||||
|
||||
// Recharts' "dataMin - N" domain expressions don't combine reliably with
|
||||
// reversed axes, so the padded numeric domain is computed directly instead.
|
||||
function paddedDomain(values: Array<number | null | undefined>, pad: number): [number, number] {
|
||||
const nums = values.filter((v): v is number => v != null);
|
||||
if (nums.length === 0) return [0, 1];
|
||||
return [Math.min(...nums) - pad, Math.max(...nums) + pad];
|
||||
}
|
||||
|
||||
// Shows each lap's actual pace/HR against the expected band from the
|
||||
// activity's structured Garmin workout (see backend's alignWorkoutTargets).
|
||||
// Renders nothing if no lap has a resolved target -- most activities aren't
|
||||
// from a structured workout, and an empty chart isn't useful.
|
||||
export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
|
||||
const hasPaceTarget = laps.some((l) => l.TargetPaceLowMps != null && l.TargetPaceHighMps != null);
|
||||
const hasHRTarget = laps.some((l) => l.TargetHRLowBpm != null && l.TargetHRHighBpm != null);
|
||||
if (!hasPaceTarget && !hasHRTarget) return null;
|
||||
|
||||
const data = laps.map((l, i) => ({
|
||||
lap: i + 1,
|
||||
actualPace: paceSecPerKm(l.AvgSpeedMps),
|
||||
// Pace (sec/km) is inverted vs speed (m/s): the *faster* (higher) speed
|
||||
// bound is the *lower* (faster) pace bound.
|
||||
targetPaceLow: l.TargetPaceHighMps != null ? paceSecPerKm(l.TargetPaceHighMps) : null,
|
||||
targetPaceHigh: 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);
|
||||
const hrDomain = paddedDomain(data.flatMap((d) => [d.actualHR, d.targetHRLow, d.targetHRHigh]), 5);
|
||||
|
||||
return (
|
||||
<div className="expected-actual-charts">
|
||||
{hasPaceTarget && (
|
||||
<div className="mini-chart">
|
||||
<span className="mini-chart-label">Pace 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
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart";
|
||||
import type { ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
|
||||
|
||||
const UNSORTED = "__unsorted__";
|
||||
@@ -129,6 +130,8 @@ export function ReviewQueue() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ExpectedVsActualChart laps={item.laps} />
|
||||
|
||||
<div className="review-item-actions">
|
||||
{manuallyAssignableKinds.map((k) => (
|
||||
<button
|
||||
|
||||
@@ -45,6 +45,13 @@ export interface Lap {
|
||||
IntensityType: string;
|
||||
HRDriftBpmPerMin: number | null;
|
||||
HRRecoveryBpmPerMin: number | null;
|
||||
// Expected band for this lap, resolved from the activity's structured
|
||||
// Garmin workout when its steps line up 1:1 with the recorded laps. Null
|
||||
// when there's no structured workout, or the step/lap counts don't match.
|
||||
TargetPaceLowMps: number | null;
|
||||
TargetPaceHighMps: number | null;
|
||||
TargetHRLowBpm: number | null;
|
||||
TargetHRHighBpm: number | null;
|
||||
}
|
||||
|
||||
export interface ActivityListItem extends Activity {
|
||||
@@ -111,6 +118,7 @@ export interface KindAssignment {
|
||||
|
||||
export interface ReviewQueueItem extends KindAssignment {
|
||||
activity: Activity;
|
||||
laps: Lap[];
|
||||
}
|
||||
|
||||
export interface ProgressionPoint {
|
||||
|
||||
Reference in New Issue
Block a user