package integration import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "time" "rideaware/internal/config" "rideaware/internal/workout" ) const intervalsBaseURL = "https://intervals.icu/api/v1" const intervalsProvider = "intervals_icu" type IntervalsClient struct { oauthService *OAuthService workoutRepo *workout.Repository } func NewIntervalsClient() *IntervalsClient { return &IntervalsClient{ oauthService: NewOAuthService(), workoutRepo: workout.NewRepository(), } } // SaveApiKey stores the user's Intervals.icu API key encrypted in the oauth_connections table. func (c *IntervalsClient) SaveApiKey(userID uint, apiKey string) error { // Use a far-future expiry so GetValidToken never marks it expired tokenResp := &TokenResponse{ AccessToken: apiKey, ExpiresIn: 100 * 365 * 24 * 3600, } return c.oauthService.SaveConnection(userID, intervalsProvider, tokenResp) } // getApiKey retrieves and decrypts the stored API key for a user. func (c *IntervalsClient) getApiKey(userID uint) (string, error) { conn, err := c.oauthService.repo.GetConnection(userID, intervalsProvider) if err != nil { return "", fmt.Errorf("intervals.icu not connected") } if conn.Status != "active" { return "", fmt.Errorf("intervals.icu connection is %s, please reconnect", conn.Status) } return Decrypt(conn.AccessToken, config.OAuth.EncryptionKey) } // intervalsRequest performs an authenticated request to the Intervals.icu API. func (c *IntervalsClient) intervalsRequest(method, path string, apiKey string, body interface{}) (*http.Response, error) { var reqBody io.Reader if body != nil { data, err := json.Marshal(body) if err != nil { return nil, err } reqBody = bytes.NewReader(data) } req, err := http.NewRequest(method, intervalsBaseURL+path, reqBody) if err != nil { return nil, err } req.SetBasicAuth("API_KEY", apiKey) if body != nil { req.Header.Set("Content-Type", "application/json") } return http.DefaultClient.Do(req) } // ---- Workout push structures ---- type intervalsEventPayload struct { StartDateLocal string `json:"start_date_local"` Category string `json:"category"` Name string `json:"name"` Description string `json:"description,omitempty"` Type string `json:"type"` MovingTime int `json:"moving_time"` WorkoutDoc *intervalsWorkoutDoc `json:"workout_doc,omitempty"` } type intervalsWorkoutDoc struct { Description string `json:"description,omitempty"` Duration int `json:"duration"` Target string `json:"target"` Steps []intervalsStep `json:"steps"` } type intervalsStep struct { Duration int `json:"duration"` Intensity string `json:"intensity"` Warmup bool `json:"warmup,omitempty"` Cooldown bool `json:"cooldown,omitempty"` Power *intervalsPowerValue `json:"power,omitempty"` Cadence *intervalsCadenceValue `json:"cadence,omitempty"` } type intervalsPowerValue struct { Value float64 `json:"value,omitempty"` Start float64 `json:"start,omitempty"` End float64 `json:"end,omitempty"` Units string `json:"units"` } type intervalsCadenceValue struct { Value float64 `json:"value"` Units string `json:"units"` } func segmentToStep(seg workout.WorkoutSegment) intervalsStep { t := strings.ToLower(seg.Type) step := intervalsStep{ Duration: seg.Duration, } switch t { case "warmup", "warm_up": step.Intensity = "warmup" step.Warmup = true case "cooldown", "cool_down": step.Intensity = "cooldown" step.Cooldown = true case "recovery": step.Intensity = "recovery" case "interval": step.Intensity = "interval" default: step.Intensity = "active" } // Power low := seg.PowerLow high := seg.PowerHigh single := seg.Power if single > 0 { step.Power = &intervalsPowerValue{Value: single, Units: "%ftp"} } else if low > 0 || high > 0 { if low == high { step.Power = &intervalsPowerValue{Value: low, Units: "%ftp"} } else { step.Power = &intervalsPowerValue{Start: low, End: high, Units: "%ftp"} } } // Cadence if seg.Cadence > 0 { step.Cadence = &intervalsCadenceValue{Value: float64(seg.Cadence), Units: "rpm"} } return step } // PushWorkout pushes a planned workout to the user's Intervals.icu calendar. func (c *IntervalsClient) PushWorkout(workoutID, userID uint) error { apiKey, err := c.getApiKey(userID) if err != nil { return err } w, err := c.workoutRepo.GetWorkoutByID(workoutID, userID) if err != nil { return fmt.Errorf("workout not found: %w", err) } // Build workout_doc from segments var doc *intervalsWorkoutDoc if len(w.WorkoutData.Segments) > 0 { steps := make([]intervalsStep, 0, len(w.WorkoutData.Segments)) for _, seg := range w.WorkoutData.Segments { steps = append(steps, segmentToStep(seg)) } doc = &intervalsWorkoutDoc{ Description: w.Description, Duration: w.Duration, Target: "POWER", Steps: steps, } } payload := intervalsEventPayload{ StartDateLocal: w.ScheduledDate.Format("2006-01-02") + "T00:00:00", Category: "WORKOUT", Name: w.Title, Description: w.Description, Type: "Ride", MovingTime: w.Duration, WorkoutDoc: doc, } resp, err := c.intervalsRequest("POST", "/athlete/0/events", apiKey, payload) if err != nil { return fmt.Errorf("failed to push workout to Intervals.icu: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("Intervals.icu API returned status %d: %s", resp.StatusCode, string(body)) } return nil } // ---- Activity sync structures ---- type intervalsActivity struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` StartDateLocal string `json:"start_date_local"` MovingTime int `json:"moving_time"` Distance float64 `json:"distance"` AverageWatts int `json:"average_watts"` AverageHeartrate int `json:"average_heartrate"` MaxWatts int `json:"max_watts"` MaxHeartrate int `json:"max_heartrate"` Calories int `json:"calories"` TotalElevationGain int `json:"total_elevation_gain"` } // SyncActivities fetches recent completed activities from Intervals.icu and imports them. // Returns the number of new activities imported. func (c *IntervalsClient) SyncActivities(userID uint, days int) (int, error) { apiKey, err := c.getApiKey(userID) if err != nil { return 0, err } if days <= 0 || days > 365 { days = 30 } oldest := time.Now().AddDate(0, 0, -days).Format("2006-01-02") newest := time.Now().Format("2006-01-02") resp, err := c.intervalsRequest("GET", fmt.Sprintf("/athlete/0/activities?oldest=%s&newest=%s", oldest, newest), apiKey, nil) if err != nil { return 0, fmt.Errorf("failed to fetch activities from Intervals.icu: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return 0, fmt.Errorf("Intervals.icu API returned status %d: %s", resp.StatusCode, string(body)) } var activities []intervalsActivity if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil { return 0, fmt.Errorf("failed to parse Intervals.icu activities: %w", err) } imported := 0 for _, act := range activities { // Parse date from start_date_local (format: "2026-01-15T08:30:00") t, err := time.Parse("2006-01-02T15:04:05", act.StartDateLocal) if err != nil { // Try alternate format without time t, err = time.Parse("2006-01-02", act.StartDateLocal[:10]) if err != nil { continue } } dateStr := t.Format("2006-01-02") // Skip if a completed workout already exists on this date existing, _ := c.workoutRepo.GetCompletedWorkoutOnDate(userID, dateStr) if existing != nil { continue } // Map activity type to RideAware type workoutType := act.Type if workoutType == "" { workoutType = "Ride" } w := &workout.Workout{ UserID: userID, Title: act.Name, Type: workoutType, Status: "completed", ScheduledDate: t, Duration: act.MovingTime, Distance: act.Distance / 1000, // metres → km AvgPower: act.AverageWatts, AvgHR: act.AverageHeartrate, MaxPower: act.MaxWatts, MaxHR: act.MaxHeartrate, CaloriesBurned: act.Calories, ElevGain: act.TotalElevationGain, } if err := c.workoutRepo.CreateWorkout(w); err == nil { imported++ } } return imported, nil }