309 lines
8.6 KiB
Go
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
|
|
}
|