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:
@@ -164,6 +164,12 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertKindAssignment: %v", err)
|
||||
}
|
||||
targetLow, targetHigh := 3.0, 3.5
|
||||
if err := db.ReplaceLaps(ctx, activityID, []store.Lap{
|
||||
{LapIndex: 1, TargetPaceLowMps: &targetLow, TargetPaceHighMps: &targetHigh},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReplaceLaps: %v", err)
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||
@@ -175,6 +181,10 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
if len(queue) != 1 {
|
||||
t.Fatalf("expected 1 item in review queue, got %d: %s", len(queue), rec.Body.String())
|
||||
}
|
||||
laps, _ := queue[0]["laps"].([]any)
|
||||
if len(laps) != 1 {
|
||||
t.Fatalf("expected 1 lap in review queue item, got %d: %s", len(laps), rec.Body.String())
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID})
|
||||
if rec.Code != http.StatusOK {
|
||||
|
||||
@@ -21,6 +21,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
type item struct {
|
||||
store.KindAssignment
|
||||
Activity store.Activity `json:"activity"`
|
||||
Laps []store.Lap `json:"laps"`
|
||||
}
|
||||
items := make([]item, 0, len(queue))
|
||||
for _, a := range queue {
|
||||
@@ -32,7 +33,12 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{KindAssignment: a, Activity: activity})
|
||||
laps, err := s.DB.LapsForActivity(r.Context(), a.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
items = append(items, item{KindAssignment: a, Activity: activity, Laps: laps})
|
||||
}
|
||||
|
||||
// Most recent run first, by when the activity actually happened (not
|
||||
|
||||
@@ -36,6 +36,8 @@ type Client interface {
|
||||
GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error)
|
||||
// GetActivityDetails fetches raw per-second telemetry for one activity.
|
||||
GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error)
|
||||
// GetWorkoutByID fetches a structured workout's step-by-step plan.
|
||||
GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error)
|
||||
// Close terminates the subprocess, if running.
|
||||
Close() error
|
||||
}
|
||||
@@ -270,6 +272,27 @@ func (c *mcpClient) GetActivityDetails(ctx context.Context, activityID int64) (A
|
||||
return details, nil
|
||||
}
|
||||
|
||||
func (c *mcpClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if err := c.ensureStarted(ctx); err != nil {
|
||||
return Workout{}, err
|
||||
}
|
||||
msg, err := c.callTool(ctx, "get_workout_by_id", map[string]any{
|
||||
"workout_id": strconv.FormatInt(workoutID, 10),
|
||||
})
|
||||
if err != nil {
|
||||
return Workout{}, err
|
||||
}
|
||||
|
||||
var workout Workout
|
||||
if err := json.Unmarshal([]byte(msg), &workout); err != nil {
|
||||
return Workout{}, fmt.Errorf("get_workout_by_id did not return valid JSON (%q): %w", truncate(msg, 200), err)
|
||||
}
|
||||
return workout, nil
|
||||
}
|
||||
|
||||
func (c *mcpClient) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -14,6 +14,7 @@ type Client struct {
|
||||
Activities []garmin.Activity
|
||||
Splits map[int64]garmin.ActivitySplits
|
||||
Details map[int64]garmin.ActivityDetails
|
||||
Workouts map[int64]garmin.Workout
|
||||
Err error // if set, every call returns this error
|
||||
authResultCursor int
|
||||
ClosedCalled bool
|
||||
@@ -72,6 +73,13 @@ func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (garm
|
||||
return c.Details[activityID], nil
|
||||
}
|
||||
|
||||
func (c *Client) GetWorkoutByID(ctx context.Context, workoutID int64) (garmin.Workout, error) {
|
||||
if c.Err != nil {
|
||||
return garmin.Workout{}, c.Err
|
||||
}
|
||||
return c.Workouts[workoutID], nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateCredentials(email, password string) {
|
||||
c.LastEmail = email
|
||||
c.LastPassword = password
|
||||
|
||||
@@ -38,6 +38,9 @@ type Activity struct {
|
||||
ActivityName string `json:"activityName"`
|
||||
ActivityType ActivityType `json:"activityType"`
|
||||
EventType EventType `json:"eventType"`
|
||||
// WorkoutID, when set, links this activity to the structured workout
|
||||
// (get_workout_by_id) it was recorded from.
|
||||
WorkoutID *int64 `json:"workoutId"`
|
||||
BeginTimestamp int64 `json:"beginTimestamp"`
|
||||
StartTimeGMT string `json:"startTimeGMT"`
|
||||
StartTimeLocal string `json:"startTimeLocal"`
|
||||
@@ -95,6 +98,85 @@ type ActivitySplits struct {
|
||||
Laps []Lap `json:"lapDTOs"`
|
||||
}
|
||||
|
||||
// WorkoutTargetType mirrors a workout step's "targetType" object, e.g.
|
||||
// {"workoutTargetTypeKey": "pace.zone"}. Known keys include "no.target",
|
||||
// "pace.zone", "heart.rate.zone", "cadence", "power.zone".
|
||||
type WorkoutTargetType struct {
|
||||
TypeKey string `json:"workoutTargetTypeKey"`
|
||||
}
|
||||
|
||||
// WorkoutEndCondition mirrors a workout step's "endCondition" object, e.g.
|
||||
// {"conditionTypeKey": "time"} paired with EndConditionValue (seconds for
|
||||
// "time", meters for "distance").
|
||||
type WorkoutEndCondition struct {
|
||||
TypeKey string `json:"conditionTypeKey"`
|
||||
}
|
||||
|
||||
// WorkoutStep is one entry of a workout segment's "workoutSteps" list. It's
|
||||
// either a leaf step (Type == "ExecutableStepDTO") or a repeat group (Type
|
||||
// == "RepeatGroupDTO", holding its own nested Steps and NumberOfIterations
|
||||
// instead of a target/end-condition). Workout.FlattenSteps expands repeat
|
||||
// groups into their repeated leaf steps.
|
||||
//
|
||||
// TargetValueOne/TargetValueTwo are an unordered {low, high} pair (Garmin
|
||||
// doesn't guarantee One < Two): meters/second for "pace.zone", bpm for
|
||||
// "heart.rate.zone" with a custom range. ZoneNumber (1-5) is set instead of
|
||||
// TargetValueOne/Two when the step targets a named HR zone rather than a
|
||||
// custom bpm range -- resolving that to bpm bounds needs the user's Karvonen
|
||||
// profile, not anything on the step itself.
|
||||
type WorkoutStep struct {
|
||||
Type string `json:"type"`
|
||||
EndCondition WorkoutEndCondition `json:"endCondition"`
|
||||
EndConditionValue float64 `json:"endConditionValue"`
|
||||
TargetType WorkoutTargetType `json:"targetType"`
|
||||
TargetValueOne *float64 `json:"targetValueOne"`
|
||||
TargetValueTwo *float64 `json:"targetValueTwo"`
|
||||
ZoneNumber *int `json:"zoneNumber"`
|
||||
NumberOfIterations int `json:"numberOfIterations"`
|
||||
Steps []WorkoutStep `json:"workoutSteps"`
|
||||
}
|
||||
|
||||
// WorkoutSegment mirrors one entry of a workout's "workoutSegments" list.
|
||||
type WorkoutSegment struct {
|
||||
SegmentOrder int `json:"segmentOrder"`
|
||||
Steps []WorkoutStep `json:"workoutSteps"`
|
||||
}
|
||||
|
||||
// Workout mirrors get_workout_by_id()'s response: a structured workout's
|
||||
// step-by-step plan.
|
||||
type Workout struct {
|
||||
WorkoutID int64 `json:"workoutId"`
|
||||
WorkoutName string `json:"workoutName"`
|
||||
Segments []WorkoutSegment `json:"workoutSegments"`
|
||||
}
|
||||
|
||||
// FlattenSteps expands repeat groups (RepeatGroupDTO) into their repeated
|
||||
// leaf steps, in execution order across every segment, so the result can be
|
||||
// zipped 1:1 against an activity's recorded laps.
|
||||
func (w Workout) FlattenSteps() []WorkoutStep {
|
||||
var out []WorkoutStep
|
||||
var walk func(steps []WorkoutStep)
|
||||
walk = func(steps []WorkoutStep) {
|
||||
for _, s := range steps {
|
||||
if s.Type == "RepeatGroupDTO" {
|
||||
iterations := s.NumberOfIterations
|
||||
if iterations < 1 {
|
||||
iterations = 1
|
||||
}
|
||||
for i := 0; i < iterations; i++ {
|
||||
walk(s.Steps)
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
for _, seg := range w.Segments {
|
||||
walk(seg.Steps)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MetricDescriptor maps a named metric to its index within each
|
||||
// ActivityDetailMetrics row. The index is NOT stable across activities or
|
||||
// devices and must always be read from this descriptor list at parse time.
|
||||
|
||||
@@ -42,6 +42,55 @@ func TestExtractSamples_UsesDescriptorIndexNotPosition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkoutFlattenSteps_ExpandsRepeatGroupsInOrder(t *testing.T) {
|
||||
workout := Workout{
|
||||
Segments: []WorkoutSegment{
|
||||
{
|
||||
SegmentOrder: 1,
|
||||
Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", EndConditionValue: 1},
|
||||
{
|
||||
Type: "RepeatGroupDTO",
|
||||
NumberOfIterations: 3,
|
||||
Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", EndConditionValue: 2},
|
||||
{Type: "ExecutableStepDTO", EndConditionValue: 3},
|
||||
},
|
||||
},
|
||||
{Type: "ExecutableStepDTO", EndConditionValue: 4},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flat := workout.FlattenSteps()
|
||||
if len(flat) != 8 {
|
||||
t.Fatalf("expected 8 flattened steps (1 + 3*2 + 1), got %d", len(flat))
|
||||
}
|
||||
want := []float64{1, 2, 3, 2, 3, 2, 3, 4}
|
||||
for i, s := range flat {
|
||||
if s.EndConditionValue != want[i] {
|
||||
t.Errorf("flat[%d].EndConditionValue = %v, want %v", i, s.EndConditionValue, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkoutFlattenSteps_ZeroIterationsTreatedAsOne(t *testing.T) {
|
||||
workout := Workout{
|
||||
Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "RepeatGroupDTO", NumberOfIterations: 0, Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", EndConditionValue: 1},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
flat := workout.FlattenSteps()
|
||||
if len(flat) != 1 {
|
||||
t.Fatalf("expected 1 flattened step, got %d", len(flat))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSamples_MissingDescriptorYieldsNilField(t *testing.T) {
|
||||
details := ActivityDetails{
|
||||
MetricDescriptors: []MetricDescriptor{
|
||||
|
||||
@@ -15,6 +15,7 @@ type Activity struct {
|
||||
ActivityName string
|
||||
ActivityType string
|
||||
EventTypeKey string
|
||||
WorkoutID *int64
|
||||
StartTimeUTC string
|
||||
BeginTimestampMs int64
|
||||
DurationSeconds float64
|
||||
@@ -50,18 +51,19 @@ type Activity struct {
|
||||
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO activities (
|
||||
garmin_activity_id, activity_name, activity_type, event_type_key, start_time_utc,
|
||||
garmin_activity_id, activity_name, activity_type, event_type_key, workout_id, start_time_utc,
|
||||
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
|
||||
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
|
||||
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
|
||||
training_effect_label, vo2max_value,
|
||||
hr_time_in_zone_1, hr_time_in_zone_2, hr_time_in_zone_3, hr_time_in_zone_4, hr_time_in_zone_5,
|
||||
raw_json, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
|
||||
ON CONFLICT(garmin_activity_id) DO UPDATE SET
|
||||
activity_name=excluded.activity_name,
|
||||
activity_type=excluded.activity_type,
|
||||
event_type_key=excluded.event_type_key,
|
||||
workout_id=excluded.workout_id,
|
||||
start_time_utc=excluded.start_time_utc,
|
||||
begin_timestamp_ms=excluded.begin_timestamp_ms,
|
||||
duration_seconds=excluded.duration_seconds,
|
||||
@@ -86,7 +88,7 @@ func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
|
||||
raw_json=excluded.raw_json,
|
||||
updated_at=datetime('now')
|
||||
`,
|
||||
a.GarminActivityID, a.ActivityName, a.ActivityType, a.EventTypeKey, a.StartTimeUTC,
|
||||
a.GarminActivityID, a.ActivityName, a.ActivityType, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC,
|
||||
a.BeginTimestampMs, a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
|
||||
a.AvgSpeedMps, a.MaxSpeedMps, a.ElevationGainM, a.ElevationLossM,
|
||||
a.Calories, a.LapCount, a.AerobicTrainingEffect, a.AnaerobicTrainingEffect,
|
||||
@@ -108,7 +110,7 @@ func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
|
||||
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
|
||||
var a Activity
|
||||
err := row.Scan(
|
||||
&a.ID, &a.GarminActivityID, &a.ActivityName, &a.ActivityType, &a.EventTypeKey, &a.StartTimeUTC,
|
||||
&a.ID, &a.GarminActivityID, &a.ActivityName, &a.ActivityType, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
|
||||
&a.BeginTimestampMs, &a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
|
||||
&a.AvgSpeedMps, &a.MaxSpeedMps, &a.ElevationGainM, &a.ElevationLossM,
|
||||
&a.Calories, &a.LapCount, &a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect,
|
||||
@@ -121,7 +123,7 @@ func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
|
||||
}
|
||||
|
||||
const activityColumns = `
|
||||
id, garmin_activity_id, activity_name, activity_type, event_type_key, start_time_utc,
|
||||
id, garmin_activity_id, activity_name, activity_type, event_type_key, workout_id, start_time_utc,
|
||||
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
|
||||
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
|
||||
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
|
||||
|
||||
@@ -23,6 +23,16 @@ type Lap struct {
|
||||
IntensityType string
|
||||
HRDriftBpmPerMin *float64
|
||||
HRRecoveryBpmPerMin *float64
|
||||
// TargetPaceLowMps/High and TargetHRLowBpm/High hold the expected band
|
||||
// for this lap, resolved from the activity's structured Garmin workout
|
||||
// (see internal/sync's alignWorkoutTargets) when its steps line up 1:1
|
||||
// with the recorded laps. Nil when the activity has no structured
|
||||
// workout, the step counts don't match, or the step targets neither
|
||||
// pace nor heart rate.
|
||||
TargetPaceLowMps *float64
|
||||
TargetPaceHighMps *float64
|
||||
TargetHRLowBpm *float64
|
||||
TargetHRHighBpm *float64
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
@@ -44,11 +54,13 @@ func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) err
|
||||
INSERT INTO laps (
|
||||
activity_id, lap_index, start_time_utc, duration_seconds, distance_meters,
|
||||
avg_hr, max_hr, avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
|
||||
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min, raw_json
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
|
||||
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
activityID, l.LapIndex, l.StartTimeUTC, l.DurationSeconds, l.DistanceMeters,
|
||||
l.AvgHR, l.MaxHR, l.AvgSpeedMps, l.MaxSpeedMps, l.ElevationGainM, l.ElevationLossM,
|
||||
l.IntensityType, l.HRDriftBpmPerMin, l.HRRecoveryBpmPerMin, l.RawJSON,
|
||||
l.IntensityType, l.HRDriftBpmPerMin, l.HRRecoveryBpmPerMin,
|
||||
l.TargetPaceLowMps, l.TargetPaceHighMps, l.TargetHRLowBpm, l.TargetHRHighBpm, l.RawJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert lap %d for activity %d: %w", l.LapIndex, activityID, err)
|
||||
@@ -62,7 +74,8 @@ func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, err
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, activity_id, lap_index, start_time_utc, duration_seconds, distance_meters,
|
||||
avg_hr, max_hr, avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
|
||||
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min, raw_json
|
||||
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
|
||||
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
|
||||
FROM laps WHERE activity_id = ? ORDER BY lap_index`, activityID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("laps for activity %d: %w", activityID, err)
|
||||
@@ -75,7 +88,8 @@ func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, err
|
||||
if err := rows.Scan(
|
||||
&l.ID, &l.ActivityID, &l.LapIndex, &l.StartTimeUTC, &l.DurationSeconds, &l.DistanceMeters,
|
||||
&l.AvgHR, &l.MaxHR, &l.AvgSpeedMps, &l.MaxSpeedMps, &l.ElevationGainM, &l.ElevationLossM,
|
||||
&l.IntensityType, &l.HRDriftBpmPerMin, &l.HRRecoveryBpmPerMin, &l.RawJSON,
|
||||
&l.IntensityType, &l.HRDriftBpmPerMin, &l.HRRecoveryBpmPerMin,
|
||||
&l.TargetPaceLowMps, &l.TargetPaceHighMps, &l.TargetHRLowBpm, &l.TargetHRHighBpm, &l.RawJSON,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan lap row: %w", err)
|
||||
}
|
||||
|
||||
12
backend/internal/store/migrations/0008_workout_targets.sql
Normal file
12
backend/internal/store/migrations/0008_workout_targets.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Structured Garmin workouts (created in Garmin Connect or a training plan
|
||||
-- tool) attach a per-step target pace/HR zone. An activity recorded from one
|
||||
-- carries that workout's id; when its laps line up 1:1 with the workout's
|
||||
-- flattened steps (see internal/sync's alignWorkoutTargets), the expected
|
||||
-- band is resolved and stored per lap so the Review Queue can plot expected
|
||||
-- vs actual pace/HR without a live Garmin call on every page view.
|
||||
ALTER TABLE activities ADD COLUMN workout_id INTEGER;
|
||||
|
||||
ALTER TABLE laps ADD COLUMN target_pace_low_mps REAL;
|
||||
ALTER TABLE laps ADD COLUMN target_pace_high_mps REAL;
|
||||
ALTER TABLE laps ADD COLUMN target_hr_low_bpm REAL;
|
||||
ALTER TABLE laps ADD COLUMN target_hr_high_bpm REAL;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -62,6 +62,85 @@ func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) {
|
||||
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}}
|
||||
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "no.target"}},
|
||||
}},
|
||||
}}
|
||||
|
||||
targets := alignWorkoutTargets(laps, workout)
|
||||
if len(targets) != 2 {
|
||||
t.Fatalf("expected 2 aligned targets, got %d", len(targets))
|
||||
}
|
||||
if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" {
|
||||
t.Errorf("targets[0] = %+v, want pace.zone step", targets[0])
|
||||
}
|
||||
if targets[1] == nil || targets[1].TargetType.TypeKey != "no.target" {
|
||||
t.Errorf("targets[1] = %+v, want no.target step", targets[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
|
||||
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
||||
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO"},
|
||||
}},
|
||||
}}
|
||||
|
||||
targets := alignWorkoutTargets(laps, workout)
|
||||
if len(targets) != 3 {
|
||||
t.Fatalf("expected 3 slots (one per lap), got %d", len(targets))
|
||||
}
|
||||
for i, target := range targets {
|
||||
if target != nil {
|
||||
t.Errorf("targets[%d] = %+v, want nil on count mismatch", i, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetPaceRange_OnlyForPaceZoneAndOrdersLowHigh(t *testing.T) {
|
||||
lo, hi := targetPaceRange(garmin.WorkoutStep{
|
||||
TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(4), TargetValueTwo: f(3),
|
||||
})
|
||||
if lo == nil || hi == nil || *lo != 3 || *hi != 4 {
|
||||
t.Errorf("targetPaceRange = (%v, %v), want (3, 4) reordered", lo, hi)
|
||||
}
|
||||
|
||||
lo, hi = targetPaceRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)})
|
||||
if lo != nil || hi != nil {
|
||||
t.Errorf("targetPaceRange for a non-pace step = (%v, %v), want (nil, nil)", lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
|
||||
lo, hi := targetHRRange(garmin.WorkoutStep{
|
||||
TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(160), TargetValueTwo: f(150),
|
||||
}, store.Profile{})
|
||||
if lo == nil || hi == nil || *lo != 150 || *hi != 160 {
|
||||
t.Errorf("custom bpm range = (%v, %v), want (150, 160) reordered", lo, hi)
|
||||
}
|
||||
|
||||
zone := 3
|
||||
profile := store.Profile{
|
||||
MaxHeartRate: f(190), RestingHeartRate: f(50),
|
||||
HRZone3MinPct: 70, HRZone3MaxPct: 80,
|
||||
}
|
||||
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, profile)
|
||||
// Karvonen: restHR + pct * (maxHR - restHR) = 50 + 0.70*140 = 148, 50 + 0.80*140 = 162
|
||||
if lo == nil || hi == nil || *lo != 148 || *hi != 162 {
|
||||
t.Errorf("zone-based bpm range = (%v, %v), want (148, 162)", lo, hi)
|
||||
}
|
||||
|
||||
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, store.Profile{})
|
||||
if lo != nil || hi != nil {
|
||||
t.Errorf("zone-based range without max/resting HR configured = (%v, %v), want (nil, nil)", lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
@@ -164,6 +243,106 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const garminActivityID = 55
|
||||
const workoutID = 999
|
||||
workoutIDPtr := int64(workoutID)
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
||||
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
|
||||
}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
workoutID: {Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if err := svc.Backfill(ctx); err != nil {
|
||||
t.Fatalf("Backfill: %v", err)
|
||||
}
|
||||
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
||||
t.Fatalf("FillPendingDetails: %v", err)
|
||||
}
|
||||
|
||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
||||
if err != nil || len(activities) != 1 {
|
||||
t.Fatalf("ListActivities: %v, %+v", err, activities)
|
||||
}
|
||||
laps, err := db.LapsForActivity(ctx, activities[0].ID)
|
||||
if err != nil || len(laps) != 1 {
|
||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||
}
|
||||
if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 {
|
||||
t.Errorf("TargetPaceLowMps = %v, want 3.0", laps[0].TargetPaceLowMps)
|
||||
}
|
||||
if laps[0].TargetPaceHighMps == nil || *laps[0].TargetPaceHighMps != 3.5 {
|
||||
t.Errorf("TargetPaceHighMps = %v, want 3.5", laps[0].TargetPaceHighMps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillPendingDetails_MismatchedLapCountLeavesTargetsNil(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const garminActivityID = 56
|
||||
const workoutID = 1000
|
||||
workoutIDPtr := int64(workoutID)
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
||||
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
|
||||
{LapIndex: 2, Duration: 600, ElapsedDuration: 600, Distance: 2000, IntensityType: "REST"},
|
||||
}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
// Only one step for two recorded laps -- counts don't match.
|
||||
workoutID: {Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if err := svc.Backfill(ctx); err != nil {
|
||||
t.Fatalf("Backfill: %v", err)
|
||||
}
|
||||
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
||||
t.Fatalf("FillPendingDetails: %v", err)
|
||||
}
|
||||
|
||||
activities, _ := db.ListActivities(ctx, store.ActivityFilter{})
|
||||
laps, err := db.LapsForActivity(ctx, activities[0].ID)
|
||||
if err != nil || len(laps) != 2 {
|
||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||
}
|
||||
for i, l := range laps {
|
||||
if l.TargetPaceLowMps != nil || l.TargetPaceHighMps != nil {
|
||||
t.Errorf("laps[%d] target should be nil on lap/step count mismatch, got low=%v high=%v", i, l.TargetPaceLowMps, l.TargetPaceHighMps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricContext_DerivesExpectedMetrics(t *testing.T) {
|
||||
activity := store.Activity{
|
||||
DurationSeconds: 1800,
|
||||
|
||||
@@ -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