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:
2026-07-19 11:55:13 +02:00
parent 0610897541
commit af41aa0f7f
16 changed files with 666 additions and 47 deletions

View File

@@ -25,6 +25,7 @@ func toActivityRow(a garmin.Activity) store.Activity {
ActivityName: a.ActivityName,
ActivityType: a.ActivityType.TypeKey,
EventTypeKey: a.EventType.TypeKey,
WorkoutID: a.WorkoutID,
StartTimeUTC: a.StartTimeGMT,
BeginTimestampMs: a.BeginTimestamp,
DurationSeconds: a.Duration,
@@ -64,10 +65,10 @@ func nonZero(v float64) *float64 {
// fall within that lap's time window. Lap boundaries are derived from
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps
// are contiguous and this sidesteps timezone parsing entirely.
func toLapRows(laps []garmin.Lap, samples []garmin.Sample) []store.Lap {
func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.WorkoutStep, profile store.Profile) []store.Lap {
rows := make([]store.Lap, 0, len(laps))
var elapsedStart float64
for _, l := range laps {
for i, l := range laps {
elapsedEnd := elapsedStart + l.ElapsedDuration
var driftPtr, recoveryPtr *float64
@@ -83,6 +84,12 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample) []store.Lap {
}
}
var paceLow, paceHigh, hrLow, hrHigh *float64
if i < len(targets) && targets[i] != nil {
paceLow, paceHigh = targetPaceRange(*targets[i])
hrLow, hrHigh = targetHRRange(*targets[i], profile)
}
raw, _ := json.Marshal(l)
rows = append(rows, store.Lap{
LapIndex: l.LapIndex,
@@ -98,6 +105,10 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample) []store.Lap {
IntensityType: l.IntensityType,
HRDriftBpmPerMin: driftPtr,
HRRecoveryBpmPerMin: recoveryPtr,
TargetPaceLowMps: paceLow,
TargetPaceHighMps: paceHigh,
TargetHRLowBpm: hrLow,
TargetHRHighBpm: hrHigh,
RawJSON: string(raw),
})
elapsedStart = elapsedEnd
@@ -105,6 +116,89 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample) []store.Lap {
return rows
}
// alignWorkoutTargets zips an activity's recorded laps against its
// structured workout's flattened steps, returning one *garmin.WorkoutStep
// per lap (nil where unavailable). Zipping only happens when the counts
// match exactly -- a mismatch (extra manual laps, auto-lap-by-distance also
// firing, etc.) means we can't trust the alignment, so every entry comes
// back nil rather than risk showing a target against the wrong lap.
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep {
steps := workout.FlattenSteps()
if len(steps) != len(laps) {
return make([]*garmin.WorkoutStep, len(laps))
}
out := make([]*garmin.WorkoutStep, len(steps))
for i := range steps {
s := steps[i]
out[i] = &s
}
return out
}
// targetPaceRange returns the (low, high) m/s bounds of a workout step's
// pace-zone target, or (nil, nil) if it doesn't target pace.
func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil {
return nil, nil
}
lo, hi := *step.TargetValueOne, *step.TargetValueTwo
if lo > hi {
lo, hi = hi, lo
}
return &lo, &hi
}
// targetHRRange returns the (low, high) bpm bounds of a workout step's
// heart-rate-zone target, or (nil, nil) if it doesn't target heart rate.
// Steps that target a named zone (ZoneNumber) rather than a custom bpm
// range are resolved via the user's Karvonen profile.
func targetHRRange(step garmin.WorkoutStep, profile store.Profile) (*float64, *float64) {
if step.TargetType.TypeKey != "heart.rate.zone" {
return nil, nil
}
if step.TargetValueOne != nil && step.TargetValueTwo != nil {
lo, hi := *step.TargetValueOne, *step.TargetValueTwo
if lo > hi {
lo, hi = hi, lo
}
return &lo, &hi
}
if step.ZoneNumber != nil {
if lo, hi, ok := karvonenBounds(profile, *step.ZoneNumber); ok {
return &lo, &hi
}
}
return nil, nil
}
// karvonenBounds resolves a named HR zone (1-5) to bpm bounds using the
// user's max/resting heart rate and the zone's %HRR range, both from
// Profile. ok is false when max/resting heart rate aren't configured, or
// the zone number is out of range.
func karvonenBounds(p store.Profile, zone int) (lowBpm, highBpm float64, ok bool) {
if p.MaxHeartRate == nil || p.RestingHeartRate == nil {
return 0, 0, false
}
maxHR, restHR := *p.MaxHeartRate, *p.RestingHeartRate
var minPct, maxPct float64
switch zone {
case 1:
minPct, maxPct = p.HRZone1MinPct, p.HRZone1MaxPct
case 2:
minPct, maxPct = p.HRZone2MinPct, p.HRZone2MaxPct
case 3:
minPct, maxPct = p.HRZone3MinPct, p.HRZone3MaxPct
case 4:
minPct, maxPct = p.HRZone4MinPct, p.HRZone4MaxPct
case 5:
minPct, maxPct = p.HRZone5MinPct, p.HRZone5MaxPct
default:
return 0, 0, false
}
return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true
}
func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.SampleInfo {
var out []classify.SampleInfo
for _, s := range samples {