Show the average line for single-effort-type workouts too; rebrand header to geniusrun
Previously the dashed average pace/HR line only drew for warm-up/cool-down laps. Workouts with only one kind of effort throughout (no warm-up/cool-down segmentation at all, e.g. a plain Long Run) had nothing to compare the actual trace against, so they now get the same dashed line spanning the whole activity at its overall (duration-weighted) average. UI-only rename: "smartrun" -> "geniusrun", prefixed with the genie emoji.
This commit is contained in:
@@ -24,7 +24,7 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<h1>smartrun</h1>
|
<h1>🧞♀️ geniusrun</h1>
|
||||||
<nav className="tabs">
|
<nav className="tabs">
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -131,10 +131,10 @@ interface Point {
|
|||||||
t: number;
|
t: number;
|
||||||
actualPace: number | null;
|
actualPace: number | null;
|
||||||
targetPaceRange: [number, number] | null;
|
targetPaceRange: [number, number] | null;
|
||||||
warmupCooldownAvgPace: number | null;
|
phaseAvgPace: number | null;
|
||||||
actualHR: number | null;
|
actualHR: number | null;
|
||||||
targetHRRange: [number, number] | null;
|
targetHRRange: [number, number] | null;
|
||||||
warmupCooldownAvgHR: number | null;
|
phaseAvgHR: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLapWindows(laps: Lap[]): LapWindow[] {
|
function buildLapWindows(laps: Lap[]): LapWindow[] {
|
||||||
@@ -177,6 +177,17 @@ function rangeTooltipFormatter(rangeLabel: string, formatValue: (v: number) => s
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function weightedMean(pairs: Array<[number | null, number]>): number | null {
|
||||||
|
let weightedSum = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
for (const [value, weight] of pairs) {
|
||||||
|
if (value == null) continue;
|
||||||
|
weightedSum += value * weight;
|
||||||
|
totalWeight += weight;
|
||||||
|
}
|
||||||
|
return totalWeight > 0 ? weightedSum / totalWeight : null;
|
||||||
|
}
|
||||||
|
|
||||||
// Shows actual pace/HR over elapsed time for every activity that has laps,
|
// Shows actual pace/HR over elapsed time for every activity that has laps,
|
||||||
// with the workout's warm-up/effort/recovery/cool-down structure shaded
|
// with the workout's warm-up/effort/recovery/cool-down structure shaded
|
||||||
// behind it (a light vertical line marks each phase change) and the
|
// behind it (a light vertical line marks each phase change) and the
|
||||||
@@ -186,6 +197,10 @@ function rangeTooltipFormatter(rangeLabel: string, formatValue: (v: number) => s
|
|||||||
// band is the common case, not an error. Warm-up and cool-down segments,
|
// band is the common case, not an error. Warm-up and cool-down segments,
|
||||||
// which rarely have a target, instead get a dashed line at their own
|
// which rarely have a target, instead get a dashed line at their own
|
||||||
// average pace/HR so there's still a reference point to judge them against.
|
// average pace/HR so there's still a reference point to judge them against.
|
||||||
|
// A workout with only one kind of effort throughout (no warm-up/cool-down
|
||||||
|
// segmentation at all) gets that same dashed line too, spanning the whole
|
||||||
|
// activity at its overall average -- with no structure to compare against,
|
||||||
|
// the average is the only reference point available.
|
||||||
//
|
//
|
||||||
// The actual trace is built from per-second samples when available, not lap
|
// The actual trace is built from per-second samples when available, not lap
|
||||||
// averages: a lap can span many minutes, so plotting one flat value per lap
|
// averages: a lap can span many minutes, so plotting one flat value per lap
|
||||||
@@ -197,11 +212,12 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
|
|
||||||
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);
|
||||||
|
const singleEffortType = new Set(laps.map((l) => l.IntensityType)).size === 1;
|
||||||
|
|
||||||
const lapWindows = buildLapWindows(laps);
|
const lapWindows = buildLapWindows(laps);
|
||||||
const lapTotalMin = lapWindows[lapWindows.length - 1].end;
|
const lapTotalMin = lapWindows[lapWindows.length - 1].end;
|
||||||
|
|
||||||
function warmupCooldownAvg(w: LapWindow | undefined, metric: "avgPace" | "avgHR"): number | null {
|
function phaseAvg(w: LapWindow | undefined, metric: "avgPace" | "avgHR"): number | null {
|
||||||
if (!w || (w.intensityType !== "WARMUP" && w.intensityType !== "COOLDOWN")) return null;
|
if (!w || (w.intensityType !== "WARMUP" && w.intensityType !== "COOLDOWN")) return null;
|
||||||
return w[metric];
|
return w[metric];
|
||||||
}
|
}
|
||||||
@@ -216,10 +232,10 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
t,
|
t,
|
||||||
actualPace: paceSecPerKm(s.SpeedMps),
|
actualPace: paceSecPerKm(s.SpeedMps),
|
||||||
targetPaceRange: w?.targetPaceRange ?? null,
|
targetPaceRange: w?.targetPaceRange ?? null,
|
||||||
warmupCooldownAvgPace: warmupCooldownAvg(w, "avgPace"),
|
phaseAvgPace: phaseAvg(w, "avgPace"),
|
||||||
actualHR: s.HeartRate,
|
actualHR: s.HeartRate,
|
||||||
targetHRRange: w?.targetHRRange ?? null,
|
targetHRRange: w?.targetHRRange ?? null,
|
||||||
warmupCooldownAvgHR: warmupCooldownAvg(w, "avgHR"),
|
phaseAvgHR: phaseAvg(w, "avgHR"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
elapsedMin = Math.max(lapTotalMin, points[points.length - 1].t);
|
elapsedMin = Math.max(lapTotalMin, points[points.length - 1].t);
|
||||||
@@ -230,15 +246,27 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
t: w.start,
|
t: w.start,
|
||||||
actualPace: null,
|
actualPace: null,
|
||||||
targetPaceRange: w.targetPaceRange,
|
targetPaceRange: w.targetPaceRange,
|
||||||
warmupCooldownAvgPace: warmupCooldownAvg(w, "avgPace"),
|
phaseAvgPace: phaseAvg(w, "avgPace"),
|
||||||
actualHR: null,
|
actualHR: null,
|
||||||
targetHRRange: w.targetHRRange,
|
targetHRRange: w.targetHRRange,
|
||||||
warmupCooldownAvgHR: warmupCooldownAvg(w, "avgHR"),
|
phaseAvgHR: phaseAvg(w, "avgHR"),
|
||||||
}));
|
}));
|
||||||
points.push({ ...points[points.length - 1], t: lapTotalMin });
|
points.push({ ...points[points.length - 1], t: lapTotalMin });
|
||||||
elapsedMin = lapTotalMin;
|
elapsedMin = lapTotalMin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (singleEffortType) {
|
||||||
|
// The whole workout is one undifferentiated effort -- draw one flat
|
||||||
|
// average line across the entire span instead of per-lap segments.
|
||||||
|
// Duration-weighted across laps (not just their own avg fields' plain
|
||||||
|
// mean) so a long lap counts more than a short one, and computed from
|
||||||
|
// lap data rather than samples so it still works without per-second
|
||||||
|
// telemetry.
|
||||||
|
const overallAvgPace = weightedMean(lapWindows.map((w) => [w.avgPace, w.end - w.start]));
|
||||||
|
const overallAvgHR = weightedMean(lapWindows.map((w) => [w.avgHR, w.end - w.start]));
|
||||||
|
points = points.map((p) => ({ ...p, phaseAvgPace: overallAvgPace, phaseAvgHR: overallAvgHR }));
|
||||||
|
}
|
||||||
|
|
||||||
const phaseBands = lapWindows.filter((w) => w.color).map((w) => ({ x1: w.start, x2: w.end, color: w.color! }));
|
const phaseBands = lapWindows.filter((w) => w.color).map((w) => ({ x1: w.start, x2: w.end, color: w.color! }));
|
||||||
const phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => PHASE_COLORS[t]))];
|
const phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => PHASE_COLORS[t]))];
|
||||||
// A light vertical line at every point the effort type changes (warm-up
|
// A light vertical line at every point the effort type changes (warm-up
|
||||||
@@ -291,7 +319,7 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
<Area type="stepAfter" dataKey="targetPaceRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
<Area type="stepAfter" dataKey="targetPaceRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
||||||
)}
|
)}
|
||||||
<Area type="monotone" dataKey="actualPace" stroke={PACE_COLOR} strokeWidth={2} fill={PACE_COLOR} fillOpacity={0.3} dot={false} isAnimationActive={false} name="Actual" connectNulls />
|
<Area type="monotone" dataKey="actualPace" stroke={PACE_COLOR} strokeWidth={2} fill={PACE_COLOR} fillOpacity={0.3} dot={false} isAnimationActive={false} name="Actual" connectNulls />
|
||||||
<Line type="linear" dataKey="warmupCooldownAvgPace" stroke={PACE_COLOR} strokeDasharray="4 2" strokeWidth={1.5} dot={false} isAnimationActive={false} name="Warm-up/cool-down avg" connectNulls={false} />
|
<Line type="linear" dataKey="phaseAvgPace" stroke={PACE_COLOR} strokeDasharray="4 2" strokeWidth={1.5} dot={false} isAnimationActive={false} name="Average" connectNulls={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,7 +340,7 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
<Area type="stepAfter" dataKey="targetHRRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
<Area type="stepAfter" dataKey="targetHRRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
||||||
)}
|
)}
|
||||||
<Area type="monotone" dataKey="actualHR" stroke={HR_COLOR} strokeWidth={2} fill={HR_COLOR} fillOpacity={0.3} dot={false} isAnimationActive={false} name="Actual" connectNulls />
|
<Area type="monotone" dataKey="actualHR" stroke={HR_COLOR} strokeWidth={2} fill={HR_COLOR} fillOpacity={0.3} dot={false} isAnimationActive={false} name="Actual" connectNulls />
|
||||||
<Line type="linear" dataKey="warmupCooldownAvgHR" stroke={HR_COLOR} strokeDasharray="4 2" strokeWidth={1.5} dot={false} isAnimationActive={false} name="Warm-up/cool-down avg" connectNulls={false} />
|
<Line type="linear" dataKey="phaseAvgHR" stroke={HR_COLOR} strokeDasharray="4 2" strokeWidth={1.5} dot={false} isAnimationActive={false} name="Average" connectNulls={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user