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 (
{error}
} {items.length > 0 && ( -