// Command seedsample inserts synthetic activities, laps, and workout kinds // directly into the SQLite database, then runs them through the real // classification engine -- so the frontend (Dashboard/ReviewQueue/ // WorkoutKinds) can be visually verified with realistic data without a live // Garmin account. package main import ( "context" "flag" "fmt" "log" "time" "smartrun/backend/internal/classify" "smartrun/backend/internal/garmin/mock" "smartrun/backend/internal/store" appsync "smartrun/backend/internal/sync" ) func main() { dbPath := flag.String("db", "smartrun_sample.db", "path to the SQLite database to seed") flag.Parse() ctx := context.Background() db, err := store.Open(*dbPath) if err != nil { log.Fatalf("open db: %v", err) } defer db.Close() // Set a max heart rate on the profile so avg_hr_pct_max is computed // during classification below (it's nil/unset by default). profile, err := db.GetProfile(ctx) must(err) maxHR := 190.0 profile.MaxHeartRate = &maxHR must(db.UpdateProfile(ctx, profile)) // Easy Run and Tempo's pace/HR ranges deliberately overlap a little // (330-340 sec/km, 0.70-0.85 HR%max) so a run that lands in that overlap // zone demonstrates the ambiguous-multi-match review path, not just a // gap between disjoint ranges. The taxonomy migration already seeded // these rows (by fixed name) -- update their rules in place rather than // creating new ones, since names are unique. easyID := mustFindKindID(ctx, db, "Easy") must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{ ID: easyID, Name: "Easy", Description: "Easy conversational runs", Color: "#22c55e", RuleJSON: `{"match":"all","conditions":[ {"metric":"avg_pace_sec_per_km","op":"between","value":[330,420]}, {"metric":"avg_hr_pct_max","op":"<=","value":0.85} ]}`, IsActive: true, })) tempoID := mustFindKindID(ctx, db, "Tempo") must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{ ID: tempoID, Name: "Tempo", Description: "Comfortably hard sustained effort", Color: "#f59e0b", RuleJSON: `{"match":"all","conditions":[ {"metric":"avg_pace_sec_per_km","op":"between","value":[300,340]}, {"metric":"avg_hr_pct_max","op":">=","value":0.70} ]}`, IsActive: true, })) intervalID := mustFindKindID(ctx, db, "Intervals") must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{ ID: intervalID, Name: "Intervals", Description: "Structured work/rest intervals", Color: "#ef4444", RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`, IsActive: true, })) m := &mock.Client{} svc := appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6}, nil) today := time.Now() activityIDs := []int64{} // 5 Easy runs, pace/HR trending slightly faster over time (progression). for i := 0; i < 5; i++ { start := today.AddDate(0, 0, -60+i*10) speed := 1000.0 / (390 - float64(i)*5) // pace improving from 390 -> 370 sec/km hr := 125.0 + float64(i) // pct-of-max stays comfortably under the 0.75 ceiling id := seedActivity(ctx, db, seedParams{ garminID: 1000 + int64(i), name: "Easy morning run", start: start, distance: 8000, duration: 8000 / (speed) * 1, speedMps: speed, avgHR: hr, aerobicTE: 2.5, anaerobicTE: 0.3, }) activityIDs = append(activityIDs, id) } // 3 Tempo runs. for i := 0; i < 3; i++ { start := today.AddDate(0, 0, -45+i*15) speed := 1000.0 / 320.0 // centered in Tempo's [300,340] range id := seedActivity(ctx, db, seedParams{ garminID: 2000 + int64(i), name: "Tempo run", start: start, distance: 6000, duration: 1800, speedMps: speed, avgHR: 168, aerobicTE: 3.8, anaerobicTE: 1.2, }) activityIDs = append(activityIDs, id) } // 1 Interval workout, with alternating ACTIVE/REST laps + HR samples // showing drift on the work intervals and recovery on the rest ones. { id := seedActivity(ctx, db, seedParams{ garminID: 3000, name: "Track intervals", start: today.AddDate(0, 0, -5), distance: 8000, duration: 2400, speedMps: 1000.0 / 240.0, avgHR: 165, aerobicTE: 3.0, anaerobicTE: 3.5, }) seedIntervalLapsAndSamples(ctx, db, id) activityIDs = append(activityIDs, id) } // 1 ambiguous run: pace 335 sec/km sits inside both Easy's [330,420] and // Tempo's [300,340] ranges, and HR% (0.78) satisfies both Easy's <=0.85 // and Tempo's >=0.70 -- so it should land in the review queue with two // candidates, not a clean single match. { id := seedActivity(ctx, db, seedParams{ garminID: 4000, name: "Ambiguous run", start: today.AddDate(0, 0, -2), distance: 7000, duration: 2200, speedMps: 1000.0 / 335.0, avgHR: 148, aerobicTE: 3.0, anaerobicTE: 0.8, }) activityIDs = append(activityIDs, id) } for _, id := range activityIDs { must(svc.ClassifyActivity(ctx, id)) } fmt.Printf("Seeded %d activities (kinds: Easy=%d, Tempo=%d) into %s\n", len(activityIDs), easyID, tempoID, *dbPath) } type seedParams struct { garminID int64 name string start time.Time distance float64 duration float64 speedMps float64 avgHR float64 aerobicTE float64 anaerobicTE float64 } func seedActivity(ctx context.Context, db *store.DB, p seedParams) int64 { speed := p.speedMps hr := p.avgHR aerobic := p.aerobicTE anaerobic := p.anaerobicTE id, err := db.UpsertActivity(ctx, store.Activity{ GarminActivityID: p.garminID, StartTimeUTC: p.start.Format("2006-01-02 15:04:05"), DurationSeconds: p.duration, DistanceMeters: p.distance, AvgSpeedMps: &speed, AvgHR: &hr, AerobicTrainingEffect: &aerobic, AnaerobicTrainingEffect: &anaerobic, // ActivityName/ActivityType aren't stored columns anymore -- the API // decodes them from RawJSON, so seeded activities need them here to // still show a sensible name/type in the dev UI. RawJSON: fmt.Sprintf(`{"activityName":%q,"activityType":{"typeKey":"running"}}`, p.name), }) must(err) return id } // seedIntervalLapsAndSamples gives one activity 6 alternating ACTIVE/REST // laps plus per-second HR samples: rising HR within each ACTIVE lap (drift) // and falling HR within each REST lap (recovery). func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, activityID int64) { var laps []store.Lap var samples []store.Sample elapsed := 0.0 baseHR := 140.0 for i := 0; i < 6; i++ { isActive := i%2 == 0 lapDuration := 180.0 intensity := "REST" if isActive { intensity = "ACTIVE" } var lapSamples []classify.SampleInfo for s := 0.0; s < lapDuration; s += 5 { var hr float64 if isActive { hr = baseHR + s/10 // rising through the work interval } else { hr = baseHR + 20 - s/8 // falling through the rest interval } samples = append(samples, store.Sample{ ElapsedSeconds: elapsed + s, TimestampMs: int64((elapsed + s) * 1000), HeartRate: &hr, }) lapSamples = append(lapSamples, classify.SampleInfo{ElapsedSeconds: elapsed + s, HeartRate: &hr}) } var drift, recovery *float64 if isActive { if v, ok := classify.HRDrift(lapSamples); ok { drift = &v } } else { if v, ok := classify.HRRecovery(lapSamples); ok { recovery = &v } } laps = append(laps, store.Lap{ LapIndex: i + 1, IntensityType: intensity, // DurationSeconds/AvgHR aren't stored columns anymore -- the API // decodes them from RawJSON, so seeded laps need them here to // still show a sensible duration/HR on the dev chart. RawJSON: fmt.Sprintf(`{"duration":%v,"distance":400,"averageHR":%v}`, lapDuration, baseHR), HRDriftBpmPerMin: drift, HRRecoveryBpmPerMin: recovery, }) elapsed += lapDuration } must(db.ReplaceActivitySamples(ctx, activityID, samples)) must(db.ReplaceLaps(ctx, activityID, laps)) } func must(err error) { if err != nil { log.Fatal(err) } } func mustFindKindID(ctx context.Context, db *store.DB, name string) int64 { kinds, err := db.ListWorkoutKinds(ctx, false) must(err) for _, k := range kinds { if k.Name == name { return k.ID } } log.Fatalf("seedsample: no workout kind named %q found (did migration 0004 run?)", name) return 0 }