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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user