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.
This commit is contained in:
2026-07-19 10:52:09 +02:00
parent dfc883acb6
commit 8ff3d62b2f
11 changed files with 158 additions and 27 deletions

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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) {