From 8ff3d62b2f1157f04581e0ff0d54c13443a1fb22 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sun, 19 Jul 2026 10:52:09 +0200 Subject: [PATCH] Add Race workout kind with Garmin eventType auto-detection, sync-time running filter, and pill-based review queue filter Race is the 8th fixed workout kind, seeded with a real (not placeholder) rule since Garmin Connect's eventType.typeKey reports "race" for manually-tagged race activities. Non-running activity types (padel, cycling, strength training, ...) are now dropped at sync time instead of being stored. The Review Queue's type filter is now clickable exclusive pill buttons instead of a dropdown. --- backend/internal/api/api_test.go | 6 +-- backend/internal/garmin/types.go | 8 +++ backend/internal/store/activities.go | 12 +++-- .../store/migrations/0007_race_kind.sql | 12 +++++ .../store/workoutkinds_taxonomy_test.go | 8 +-- backend/internal/store/workoutpaces_test.go | 4 +- backend/internal/sync/mapping.go | 16 ++++++ backend/internal/sync/service.go | 9 ++++ backend/internal/sync/service_test.go | 49 +++++++++++++++++++ frontend/src/App.css | 19 +++++++ frontend/src/pages/ReviewQueue.tsx | 42 +++++++++++----- 11 files changed, 158 insertions(+), 27 deletions(-) create mode 100644 backend/internal/store/migrations/0007_race_kind.sql diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 335f89d..0904fee 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -58,7 +58,7 @@ func TestHealth(t *testing.T) { } } -func TestWorkoutKindList_ReturnsSevenSeededTypesWithPaceFields(t *testing.T) { +func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) { s, _ := newTestServer(t) rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil) if rec.Code != http.StatusOK { @@ -68,8 +68,8 @@ func TestWorkoutKindList_ReturnsSevenSeededTypesWithPaceFields(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &kinds); err != nil { t.Fatalf("unmarshal: %v", err) } - if len(kinds) != 7 { - t.Fatalf("expected 7 seeded kinds, got %d", len(kinds)) + if len(kinds) != 8 { + t.Fatalf("expected 8 seeded kinds, got %d", len(kinds)) } for _, k := range kinds { if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.ExpectedHRZone != nil { diff --git a/backend/internal/garmin/types.go b/backend/internal/garmin/types.go index 8fc57a8..11c54e5 100644 --- a/backend/internal/garmin/types.go +++ b/backend/internal/garmin/types.go @@ -24,12 +24,20 @@ type ActivityType struct { TypeKey string `json:"typeKey"` } +// EventType mirrors the nested "eventType" object in get_activities(). Garmin +// Connect lets a user manually tag an activity's event type (Race, Training, +// Fitness, Recreation, ...); TypeKey is "race" for activities marked as races. +type EventType struct { + TypeKey string `json:"typeKey"` +} + // Activity mirrors the fields of interest from get_activities(); the full // original object is kept in Raw for fields not modeled here. type Activity struct { ActivityID int64 `json:"activityId"` ActivityName string `json:"activityName"` ActivityType ActivityType `json:"activityType"` + EventType EventType `json:"eventType"` BeginTimestamp int64 `json:"beginTimestamp"` StartTimeGMT string `json:"startTimeGMT"` StartTimeLocal string `json:"startTimeLocal"` diff --git a/backend/internal/store/activities.go b/backend/internal/store/activities.go index 1c53ded..2bd45a2 100644 --- a/backend/internal/store/activities.go +++ b/backend/internal/store/activities.go @@ -14,6 +14,7 @@ type Activity struct { GarminActivityID int64 ActivityName string ActivityType string + EventTypeKey string StartTimeUTC string BeginTimestampMs int64 DurationSeconds float64 @@ -49,17 +50,18 @@ 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, start_time_utc, + garmin_activity_id, activity_name, activity_type, event_type_key, 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, start_time_utc=excluded.start_time_utc, begin_timestamp_ms=excluded.begin_timestamp_ms, duration_seconds=excluded.duration_seconds, @@ -84,7 +86,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.StartTimeUTC, + a.GarminActivityID, a.ActivityName, a.ActivityType, a.EventTypeKey, 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, @@ -106,7 +108,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.StartTimeUTC, + &a.ID, &a.GarminActivityID, &a.ActivityName, &a.ActivityType, &a.EventTypeKey, &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, @@ -119,7 +121,7 @@ func scanActivity(row interface{ Scan(...any) error }) (Activity, error) { } const activityColumns = ` - id, garmin_activity_id, activity_name, activity_type, start_time_utc, + id, garmin_activity_id, activity_name, activity_type, event_type_key, 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, diff --git a/backend/internal/store/migrations/0007_race_kind.sql b/backend/internal/store/migrations/0007_race_kind.sql new file mode 100644 index 0000000..5b507c3 --- /dev/null +++ b/backend/internal/store/migrations/0007_race_kind.sql @@ -0,0 +1,12 @@ +-- "Race" is an 8th fixed workout kind. Unlike the other 7 (seeded with a +-- never-matching placeholder rule pending manual tuning), it gets a real +-- rule from day one: Garmin Connect lets a user manually tag an activity's +-- event type as "Race", and that value round-trips through get_activities() +-- as eventType.typeKey -- a genuine, deterministic signal, not a guess. +ALTER TABLE activities ADD COLUMN event_type_key TEXT NOT NULL DEFAULT ''; + +INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active) VALUES + ('Race', '', '#dc2626', '{"match":"all","conditions":[{"metric":"is_race","op":"==","value":true}]}', 0, 1); + +INSERT INTO workout_type_paces (workout_kind_id) +SELECT id FROM workout_kinds WHERE name = 'Race'; diff --git a/backend/internal/store/workoutkinds_taxonomy_test.go b/backend/internal/store/workoutkinds_taxonomy_test.go index cc784df..fa70e31 100644 --- a/backend/internal/store/workoutkinds_taxonomy_test.go +++ b/backend/internal/store/workoutkinds_taxonomy_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -func TestWorkoutTaxonomy_SeededWithSevenFixedTypes(t *testing.T) { +func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) { db := openTestDB(t) ctx := context.Background() @@ -13,13 +13,13 @@ func TestWorkoutTaxonomy_SeededWithSevenFixedTypes(t *testing.T) { if err != nil { t.Fatalf("ListWorkoutKinds: %v", err) } - if len(kinds) != 7 { - t.Fatalf("expected 7 seeded workout kinds, got %d: %+v", len(kinds), kinds) + if len(kinds) != 8 { + t.Fatalf("expected 8 seeded workout kinds, got %d: %+v", len(kinds), kinds) } wantNames := map[string]bool{ "Easy Run": false, "Long Run": false, "Threshold 30'": false, "Threshold 60'": false, - "Tempo": false, "Interval": false, "MAS Test": false, + "Tempo": false, "Interval": false, "MAS Test": false, "Race": false, } for _, k := range kinds { if _, ok := wantNames[k.Name]; !ok { diff --git a/backend/internal/store/workoutpaces_test.go b/backend/internal/store/workoutpaces_test.go index 9bbd9b7..62c783b 100644 --- a/backend/internal/store/workoutpaces_test.go +++ b/backend/internal/store/workoutpaces_test.go @@ -13,8 +13,8 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) { if err != nil { t.Fatalf("ListWorkoutTypePaces: %v", err) } - if len(all) != 7 { - t.Fatalf("expected 7 seeded pace rows (one per taxonomy kind), got %d", len(all)) + if len(all) != 8 { + t.Fatalf("expected 8 seeded pace rows (one per taxonomy kind), got %d", len(all)) } for _, p := range all { if p.PaceMinSecPerKm != nil || p.PaceMaxSecPerKm != nil || p.ExpectedHRZone != nil { diff --git a/backend/internal/sync/mapping.go b/backend/internal/sync/mapping.go index bd3f022..b7484ae 100644 --- a/backend/internal/sync/mapping.go +++ b/backend/internal/sync/mapping.go @@ -2,6 +2,7 @@ package sync import ( "encoding/json" + "strings" "time" "smartrun/backend/internal/classify" @@ -9,11 +10,21 @@ import ( "smartrun/backend/internal/store" ) +// isRunningActivityType reports whether a Garmin activityType.typeKey +// represents a running activity (running, trail_running, treadmill_running, +// track_running, indoor_running, virtual_run, ...) as opposed to other +// sports (padel, cycling, strength training, ...) that also show up in +// get_activities(). +func isRunningActivityType(typeKey string) bool { + return strings.Contains(strings.ToLower(typeKey), "run") +} + func toActivityRow(a garmin.Activity) store.Activity { return store.Activity{ GarminActivityID: a.ActivityID, ActivityName: a.ActivityName, ActivityType: a.ActivityType.TypeKey, + EventTypeKey: a.EventType.TypeKey, StartTimeUTC: a.StartTimeGMT, BeginTimestampMs: a.BeginTimestamp, DurationSeconds: a.Duration, @@ -125,9 +136,14 @@ func toSampleRows(samples []garmin.Sample) []store.Sample { // rules. maxHR is the user's configured max heart rate, used only to derive // avg_hr_pct_max (Garmin's activity/lap summaries don't include it directly). func buildMetricContext(a store.Activity, laps []store.Lap, maxHR float64) classify.MetricContext { + isRace := 0.0 + if a.EventTypeKey == "race" { + isRace = 1.0 + } ctx := classify.MetricContext{ "duration_seconds": a.DurationSeconds, "distance_meters": a.DistanceMeters, + "is_race": isRace, } if a.AvgSpeedMps != nil && *a.AvgSpeedMps > 0 { ctx["avg_pace_sec_per_km"] = 1000 / *a.AvgSpeedMps diff --git a/backend/internal/sync/service.go b/backend/internal/sync/service.go index 0d4b453..ade295b 100644 --- a/backend/internal/sync/service.go +++ b/backend/internal/sync/service.go @@ -208,6 +208,15 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st return 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err) } for _, a := range activities { + // Only running activities are of interest here; other sports (padel, + // cycling, strength training, ...) also come back from + // get_activities() but are dropped rather than stored. len(activities) + // below stays the unfiltered count, since it drives the backfill + // watermark's "reached start of history" check -- a page containing + // only non-running activities must not look like an empty page. + if !isRunningActivityType(a.ActivityType.TypeKey) { + continue + } if _, err := s.db.UpsertActivity(ctx, toActivityRow(a)); err != nil { return 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err) } diff --git a/backend/internal/sync/service_test.go b/backend/internal/sync/service_test.go index 9888803..f9d978f 100644 --- a/backend/internal/sync/service_test.go +++ b/backend/internal/sync/service_test.go @@ -62,6 +62,40 @@ func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) { } } +func TestBackfill_SkipsNonRunningActivities(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + m := &mock.Client{Activities: []garmin.Activity{ + {ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, + StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500}, + {ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "trail_running"}, + StartTimeGMT: "2026-07-02 06:00:00", Distance: 8000, Duration: 2400}, + {ActivityID: 3, ActivityType: garmin.ActivityType{TypeKey: "paddelball"}, + StartTimeGMT: "2026-07-03 06:00:00", Distance: 0, Duration: 1800}, + {ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"}, + StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800}, + }} + 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) + } + + activities, err := db.ListActivities(ctx, store.ActivityFilter{}) + if err != nil { + t.Fatalf("ListActivities: %v", err) + } + if len(activities) != 2 { + t.Fatalf("expected 2 stored (running-only) activities, got %d: %+v", len(activities), activities) + } + for _, a := range activities { + if a.GarminActivityID != 1 && a.GarminActivityID != 2 { + t.Errorf("unexpected non-running activity stored: %+v", a) + } + } +} + func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) { db := openTestDB(t) ctx := context.Background() @@ -162,6 +196,21 @@ func TestBuildMetricContext_DerivesExpectedMetrics(t *testing.T) { if got := ctx["lap_interval_pattern"]; got != 0 { t.Errorf("lap_interval_pattern = %v, want 0", got) } + if got := ctx["is_race"]; got != 0 { + t.Errorf("is_race = %v, want 0 (EventTypeKey not set)", got) + } +} + +func TestBuildMetricContext_DerivesIsRace(t *testing.T) { + ctx := buildMetricContext(store.Activity{EventTypeKey: "race"}, nil, 0) + if got := ctx["is_race"]; got != 1 { + t.Errorf("is_race = %v, want 1 when EventTypeKey is \"race\"", got) + } + + ctx = buildMetricContext(store.Activity{EventTypeKey: "training"}, nil, 0) + if got := ctx["is_race"]; got != 0 { + t.Errorf("is_race = %v, want 0 when EventTypeKey is not \"race\"", got) + } } func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) { diff --git a/frontend/src/App.css b/frontend/src/App.css index e55f529..3fbc555 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -137,6 +137,25 @@ button:disabled { cursor: default; } +.filter-pills { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.filter-pill { + border-radius: 999px; + padding: 0.35rem 0.9rem; + font-size: 0.85rem; +} + +.filter-pill.active { + background: #3b82f6; + border-color: #3b82f6; + color: #fff; +} + .empty-state { color: #9aa0ab; } diff --git a/frontend/src/pages/ReviewQueue.tsx b/frontend/src/pages/ReviewQueue.tsx index a4fc823..75a292f 100644 --- a/frontend/src/pages/ReviewQueue.tsx +++ b/frontend/src/pages/ReviewQueue.tsx @@ -59,25 +59,41 @@ export function ReviewQueue() { return items.filter((item) => candidates(item).some((c) => c.workout_kind_id === id)); }, [items, filterKindId]); + function toggleFilter(id: string) { + setFilterKindId((prev) => (prev === id ? "" : id)); + } + return (

Review Queue

{error &&

{error}

} {items.length > 0 && ( -
- +
+ + + {kinds.map((k) => ( + + ))}
)}