lots of stuff, don't truly remember
This commit is contained in:
308
internal/integration/intervals_client.go
Normal file
308
internal/integration/intervals_client.go
Normal file
@@ -0,0 +1,308 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user