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

@@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"time"
@@ -233,6 +234,10 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
if err != nil {
return err
}
profile, err := s.db.GetProfile(ctx)
if err != nil {
return fmt.Errorf("load profile: %w", err)
}
s.setProgress(0, len(pending))
defer s.setProgress(0, 0)
@@ -245,7 +250,7 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
case <-time.After(s.cfg.InterCallDelay):
}
}
if err := s.fillActivityDetails(ctx, a); err != nil {
if err := s.fillActivityDetails(ctx, a, profile); err != nil {
return fmt.Errorf("fill details for activity %d: %w", a.GarminActivityID, err)
}
if err := s.ClassifyActivity(ctx, a.ID); err != nil {
@@ -256,7 +261,7 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
return nil
}
func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity) error {
func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID)
if err != nil {
return fmt.Errorf("get_activity_splits: %w", err)
@@ -266,11 +271,21 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity) err
return fmt.Errorf("get_activity_details: %w", err)
}
targets := make([]*garmin.WorkoutStep, len(splits.Laps))
if a.WorkoutID != nil {
workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID)
if err != nil {
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
} else {
targets = alignWorkoutTargets(splits.Laps, workout)
}
}
samples := garmin.ExtractSamples(details)
if err := s.db.ReplaceActivitySamples(ctx, a.ID, toSampleRows(samples)); err != nil {
return err
}
if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples)); err != nil {
if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
return err
}
detailsRaw, _ := json.Marshal(details)