Files
rideaware-api/internal/integration/intervals_client.go
2026-05-17 20:39:47 -05:00

309 lines
8.6 KiB
Go

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
}