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:
@@ -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,
|
||||
|
||||
12
backend/internal/store/migrations/0007_race_kind.sql
Normal file
12
backend/internal/store/migrations/0007_race_kind.sql
Normal file
@@ -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';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user