package store import ( "context" "database/sql" "fmt" ) // Activity is smartrun's persisted view of a Garmin activity. Field mapping // from the Garmin/MCP response happens in internal/sync, not here, so this // package stays independent of internal/garmin. type Activity struct { ID int64 GarminActivityID int64 ActivityName string ActivityType string EventTypeKey string WorkoutID *int64 StartTimeUTC string BeginTimestampMs int64 DurationSeconds float64 DistanceMeters float64 AvgHR *float64 MaxHR *float64 AvgSpeedMps *float64 MaxSpeedMps *float64 ElevationGainM *float64 ElevationLossM *float64 Calories *float64 LapCount int AerobicTrainingEffect *float64 AnaerobicTrainingEffect *float64 TrainingEffectLabel string VO2MaxValue *float64 HrTimeInZone1 *float64 HrTimeInZone2 *float64 HrTimeInZone3 *float64 HrTimeInZone4 *float64 HrTimeInZone5 *float64 RawJSON string DetailsFetchedAt *string DetailsRawJSON *string SplitsFetchedAt *string CreatedAt string UpdatedAt string } // UpsertActivity inserts a new activity or updates the existing row for the // same garmin_activity_id (idempotent, safe to call on every sync pass), and // returns its internal id. 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, event_type_key, workout_id, 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')) 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, workout_id=excluded.workout_id, start_time_utc=excluded.start_time_utc, begin_timestamp_ms=excluded.begin_timestamp_ms, duration_seconds=excluded.duration_seconds, distance_meters=excluded.distance_meters, avg_hr=excluded.avg_hr, max_hr=excluded.max_hr, avg_speed_mps=excluded.avg_speed_mps, max_speed_mps=excluded.max_speed_mps, elevation_gain_m=excluded.elevation_gain_m, elevation_loss_m=excluded.elevation_loss_m, calories=excluded.calories, lap_count=excluded.lap_count, aerobic_training_effect=excluded.aerobic_training_effect, anaerobic_training_effect=excluded.anaerobic_training_effect, training_effect_label=excluded.training_effect_label, vo2max_value=excluded.vo2max_value, hr_time_in_zone_1=excluded.hr_time_in_zone_1, hr_time_in_zone_2=excluded.hr_time_in_zone_2, hr_time_in_zone_3=excluded.hr_time_in_zone_3, hr_time_in_zone_4=excluded.hr_time_in_zone_4, hr_time_in_zone_5=excluded.hr_time_in_zone_5, raw_json=excluded.raw_json, updated_at=datetime('now') `, a.GarminActivityID, a.ActivityName, a.ActivityType, a.EventTypeKey, a.WorkoutID, 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, a.TrainingEffectLabel, a.VO2MaxValue, a.HrTimeInZone1, a.HrTimeInZone2, a.HrTimeInZone3, a.HrTimeInZone4, a.HrTimeInZone5, a.RawJSON, ) if err != nil { return 0, fmt.Errorf("upsert activity %d: %w", a.GarminActivityID, err) } var id int64 if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ?`, a.GarminActivityID).Scan(&id); err != nil { return 0, fmt.Errorf("fetch id for activity %d: %w", a.GarminActivityID, err) } return id, nil } func scanActivity(row interface{ Scan(...any) error }) (Activity, error) { var a Activity err := row.Scan( &a.ID, &a.GarminActivityID, &a.ActivityName, &a.ActivityType, &a.EventTypeKey, &a.WorkoutID, &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, &a.TrainingEffectLabel, &a.VO2MaxValue, &a.HrTimeInZone1, &a.HrTimeInZone2, &a.HrTimeInZone3, &a.HrTimeInZone4, &a.HrTimeInZone5, &a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.CreatedAt, &a.UpdatedAt, ) return a, err } const activityColumns = ` id, garmin_activity_id, activity_name, activity_type, event_type_key, workout_id, 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, details_fetched_at, details_raw_json, splits_fetched_at, created_at, updated_at ` // GetActivity fetches one activity by its internal id. func (db *DB) GetActivity(ctx context.Context, id int64) (Activity, bool, error) { row := db.QueryRowContext(ctx, `SELECT `+activityColumns+` FROM activities WHERE id = ?`, id) a, err := scanActivity(row) if err == sql.ErrNoRows { return Activity{}, false, nil } if err != nil { return Activity{}, false, fmt.Errorf("get activity %d: %w", id, err) } return a, true, nil } // ActivityFilter narrows ListActivities results. Zero values mean "no filter". type ActivityFilter struct { FromDate string // inclusive, "YYYY-MM-DD" ToDate string // inclusive, "YYYY-MM-DD" Limit int Offset int } // ListActivities returns activities newest-first, optionally filtered by date range. func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity, error) { query := `SELECT ` + activityColumns + ` FROM activities WHERE 1=1` var args []any if f.FromDate != "" { query += ` AND start_time_utc >= ?` args = append(args, f.FromDate) } if f.ToDate != "" { query += ` AND start_time_utc <= ?` args = append(args, f.ToDate+" 23:59:59") } query += ` ORDER BY start_time_utc DESC` if f.Limit > 0 { query += ` LIMIT ? OFFSET ?` args = append(args, f.Limit, f.Offset) } rows, err := db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("list activities: %w", err) } defer rows.Close() activities := []Activity{} for rows.Next() { a, err := scanActivity(rows) if err != nil { return nil, fmt.Errorf("scan activity row: %w", err) } activities = append(activities, a) } return activities, rows.Err() } // LatestActivityStartTime returns the start_time_utc of the most recently // started activity we have, used to compute the incremental sync window. func (db *DB) LatestActivityStartTime(ctx context.Context) (string, bool, error) { var t string err := db.QueryRowContext(ctx, `SELECT start_time_utc FROM activities ORDER BY start_time_utc DESC LIMIT 1`).Scan(&t) if err == sql.ErrNoRows { return "", false, nil } if err != nil { return "", false, fmt.Errorf("latest activity start time: %w", err) } return t, true, nil } // SetActivityDetails records that get_activity_details has been fetched for // this activity, storing the raw response for future reprocessing. func (db *DB) SetActivityDetails(ctx context.Context, activityID int64, rawJSON string) error { _, err := db.ExecContext(ctx, ` UPDATE activities SET details_fetched_at = datetime('now'), details_raw_json = ?, updated_at = datetime('now') WHERE id = ?`, rawJSON, activityID) if err != nil { return fmt.Errorf("set activity %d details: %w", activityID, err) } return nil } // SetActivitySplitsFetched records that get_activity_splits has been fetched // for this activity. func (db *DB) SetActivitySplitsFetched(ctx context.Context, activityID int64) error { _, err := db.ExecContext(ctx, ` UPDATE activities SET splits_fetched_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`, activityID) if err != nil { return fmt.Errorf("set activity %d splits fetched: %w", activityID, err) } return nil } // ActivitiesMissingDetails returns activities that haven't had // get_activity_details/get_activity_splits fetched yet, for the lazy // background detail-fill pass. func (db *DB) ActivitiesMissingDetails(ctx context.Context, limit int) ([]Activity, error) { rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities WHERE details_fetched_at IS NULL OR splits_fetched_at IS NULL ORDER BY start_time_utc DESC LIMIT ?`, limit) if err != nil { return nil, fmt.Errorf("list activities missing details: %w", err) } defer rows.Close() activities := []Activity{} for rows.Next() { a, err := scanActivity(rows) if err != nil { return nil, fmt.Errorf("scan activity row: %w", err) } activities = append(activities, a) } return activities, rows.Err() } // CountActivitiesMissingDetails returns how many activities still need // get_activity_details/get_activity_splits fetched, regardless of any // per-call batch limit -- used to report overall remaining work. func (db *DB) CountActivitiesMissingDetails(ctx context.Context) (int, error) { var n int err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities WHERE details_fetched_at IS NULL OR splits_fetched_at IS NULL`).Scan(&n) if err != nil { return 0, fmt.Errorf("count activities missing details: %w", err) } return n, nil }