Files
go-garth/endpoints.md
2025-09-20 15:21:49 -07:00

19 KiB

High Priority Endpoints Implementation Guide

Overview

This guide covers implementing the most commonly requested Garmin Connect API endpoints that are currently missing from your codebase. We'll focus on the high-priority endpoints that provide detailed health and fitness data.

1. Detailed Sleep Data Implementation

Files to Create/Modify

A. Create internal/data/sleep_detailed.go

package data

import (
	"encoding/json"
	"fmt"
	"time"

	"go-garth/internal/api/client"
	"go-garth/internal/types"
)

// SleepLevel represents different sleep stages
type SleepLevel struct {
	StartGMT      time.Time `json:"startGmt"`
	EndGMT        time.Time `json:"endGmt"`
	ActivityLevel float64   `json:"activityLevel"`
	SleepLevel    string    `json:"sleepLevel"` // "deep", "light", "rem", "awake"
}

// SleepMovement represents movement during sleep
type SleepMovement struct {
	StartGMT      time.Time `json:"startGmt"`
	EndGMT        time.Time `json:"endGmt"`
	ActivityLevel float64   `json:"activityLevel"`
}

// SleepScore represents detailed sleep scoring
type SleepScore struct {
	Overall           int     `json:"overall"`
	Composition       SleepScoreBreakdown `json:"composition"`
	Revitalization    SleepScoreBreakdown `json:"revitalization"`
	Duration          SleepScoreBreakdown `json:"duration"`
	DeepPercentage    float64 `json:"deepPercentage"`
	LightPercentage   float64 `json:"lightPercentage"`
	RemPercentage     float64 `json:"remPercentage"`
	RestfulnessValue  float64 `json:"restfulnessValue"`
}

type SleepScoreBreakdown struct {
	QualifierKey   string  `json:"qualifierKey"`
	OptimalStart   float64 `json:"optimalStart"`
	OptimalEnd     float64 `json:"optimalEnd"`
	Value          float64 `json:"value"`
	IdealStartSecs *int    `json:"idealStartInSeconds"`
	IdealEndSecs   *int    `json:"idealEndInSeconds"`
}

// DetailedSleepData represents comprehensive sleep data
type DetailedSleepData struct {
	UserProfilePK          int             `json:"userProfilePk"`
	CalendarDate           time.Time       `json:"calendarDate"`
	SleepStartTimestampGMT time.Time       `json:"sleepStartTimestampGmt"`
	SleepEndTimestampGMT   time.Time       `json:"sleepEndTimestampGmt"`
	SleepStartTimestampLocal time.Time     `json:"sleepStartTimestampLocal"`
	SleepEndTimestampLocal   time.Time     `json:"sleepEndTimestampLocal"`
	UnmeasurableSleepSeconds int           `json:"unmeasurableSleepSeconds"`
	DeepSleepSeconds         int           `json:"deepSleepSeconds"`
	LightSleepSeconds        int           `json:"lightSleepSeconds"`
	RemSleepSeconds          int           `json:"remSleepSeconds"`
	AwakeSleepSeconds        int           `json:"awakeSleepSeconds"`
	DeviceRemCapable         bool          `json:"deviceRemCapable"`
	SleepLevels              []SleepLevel  `json:"sleepLevels"`
	SleepMovement            []SleepMovement `json:"sleepMovement"`
	SleepScores              *SleepScore   `json:"sleepScores"`
	AverageSpO2Value         *float64      `json:"averageSpO2Value"`
	LowestSpO2Value          *int          `json:"lowestSpO2Value"`
	HighestSpO2Value         *int          `json:"highestSpO2Value"`
	AverageRespirationValue  *float64      `json:"averageRespirationValue"`
	LowestRespirationValue   *float64      `json:"lowestRespirationValue"`
	HighestRespirationValue  *float64      `json:"highestRespirationValue"`
	AvgSleepStress           *float64      `json:"avgSleepStress"`
	BaseData
}

// NewDetailedSleepData creates a new DetailedSleepData instance
func NewDetailedSleepData() *DetailedSleepData {
	sleep := &DetailedSleepData{}
	sleep.GetFunc = sleep.get
	return sleep
}

func (d *DetailedSleepData) get(day time.Time, client *client.Client) (interface{}, error) {
	dateStr := day.Format("2006-01-02")
	path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?date=%s&nonSleepBufferMinutes=60",
		client.Username, dateStr)

	data, err := client.ConnectAPI(path, "GET", nil, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to get detailed sleep data: %w", err)
	}

	if len(data) == 0 {
		return nil, nil
	}

	var response struct {
		DailySleepDTO            *DetailedSleepData `json:"dailySleepDTO"`
		SleepMovement            []SleepMovement    `json:"sleepMovement"`
		RemSleepData            bool               `json:"remSleepData"`
		SleepLevels             []SleepLevel       `json:"sleepLevels"`
		SleepRestlessMoments    []interface{}      `json:"sleepRestlessMoments"`
		RestlessMomentsCount    int                `json:"restlessMomentsCount"`
		WellnessSpO2SleepSummaryDTO interface{}   `json:"wellnessSpO2SleepSummaryDTO"`
		WellnessEpochSPO2DataDTOList []interface{} `json:"wellnessEpochSPO2DataDTOList"`
		WellnessEpochRespirationDataDTOList []interface{} `json:"wellnessEpochRespirationDataDTOList"`
		SleepStress             interface{}        `json:"sleepStress"`
	}

	if err := json.Unmarshal(data, &response); err != nil {
		return nil, fmt.Errorf("failed to parse detailed sleep response: %w", err)
	}

	if response.DailySleepDTO == nil {
		return nil, nil
	}

	// Populate additional data
	response.DailySleepDTO.SleepMovement = response.SleepMovement
	response.DailySleepDTO.SleepLevels = response.SleepLevels

	return response.DailySleepDTO, nil
}

// GetSleepEfficiency calculates sleep efficiency percentage
func (d *DetailedSleepData) GetSleepEfficiency() float64 {
	totalTime := d.SleepEndTimestampGMT.Sub(d.SleepStartTimestampGMT).Seconds()
	sleepTime := float64(d.DeepSleepSeconds + d.LightSleepSeconds + d.RemSleepSeconds)
	if totalTime == 0 {
		return 0
	}
	return (sleepTime / totalTime) * 100
}

// GetTotalSleepTime returns total sleep time in hours
func (d *DetailedSleepData) GetTotalSleepTime() float64 {
	totalSeconds := d.DeepSleepSeconds + d.LightSleepSeconds + d.RemSleepSeconds
	return float64(totalSeconds) / 3600.0
}

B. Add methods to internal/api/client/client.go

// GetDetailedSleepData retrieves comprehensive sleep data for a date
func (c *Client) GetDetailedSleepData(date time.Time) (*types.DetailedSleepData, error) {
	sleepData := data.NewDetailedSleepData()
	result, err := sleepData.Get(date, c)
	if err != nil {
		return nil, err
	}

	if result == nil {
		return nil, nil
	}

	detailedSleep, ok := result.(*types.DetailedSleepData)
	if !ok {
		return nil, fmt.Errorf("unexpected sleep data type")
	}

	return detailedSleep, nil
}

2. Heart Rate Variability (HRV) Implementation

A. Update internal/data/hrv.go (extend existing)

Add these methods to your existing HRV implementation:

// HRVStatus represents HRV status and baseline
type HRVStatus struct {
	Status           string  `json:"status"` // "BALANCED", "UNBALANCED", "POOR"
	FeedbackPhrase   string  `json:"feedbackPhrase"`
	BaselineLowUpper int     `json:"baselineLowUpper"`
	BalancedLow      int     `json:"balancedLow"`
	BalancedUpper    int     `json:"balancedUpper"`
	MarkerValue      float64 `json:"markerValue"`
}

// DailyHRVData represents comprehensive daily HRV data
type DailyHRVData struct {
	UserProfilePK           int          `json:"userProfilePk"`
	CalendarDate            time.Time    `json:"calendarDate"`
	WeeklyAvg               *float64     `json:"weeklyAvg"`
	LastNightAvg            *float64     `json:"lastNightAvg"`
	LastNight5MinHigh       *float64     `json:"lastNight5MinHigh"`
	Baseline                HRVBaseline  `json:"baseline"`
	Status                  string       `json:"status"`
	FeedbackPhrase          string       `json:"feedbackPhrase"`
	CreateTimeStamp         time.Time    `json:"createTimeStamp"`
	HRVReadings             []HRVReading `json:"hrvReadings"`
	StartTimestampGMT       time.Time    `json:"startTimestampGmt"`
	EndTimestampGMT         time.Time    `json:"endTimestampGmt"`
	StartTimestampLocal     time.Time    `json:"startTimestampLocal"`
	EndTimestampLocal       time.Time    `json:"endTimestampLocal"`
	SleepStartTimestampGMT  time.Time    `json:"sleepStartTimestampGmt"`
	SleepEndTimestampGMT    time.Time    `json:"sleepEndTimestampGmt"`
	BaseData
}

type HRVBaseline struct {
	LowUpper      int     `json:"lowUpper"`
	BalancedLow   int     `json:"balancedLow"`
	BalancedUpper int     `json:"balancedUpper"`
	MarkerValue   float64 `json:"markerValue"`
}

// Update the existing get method in hrv.go
func (h *DailyHRVData) get(day time.Time, client *client.Client) (interface{}, error) {
	dateStr := day.Format("2006-01-02")
	path := fmt.Sprintf("/wellness-service/wellness/dailyHrvData/%s?date=%s",
		client.Username, dateStr)

	data, err := client.ConnectAPI(path, "GET", nil, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to get HRV data: %w", err)
	}

	if len(data) == 0 {
		return nil, nil
	}

	var response struct {
		HRVSummary  DailyHRVData `json:"hrvSummary"`
		HRVReadings []HRVReading `json:"hrvReadings"`
	}

	if err := json.Unmarshal(data, &response); err != nil {
		return nil, fmt.Errorf("failed to parse HRV response: %w", err)
	}

	// Combine summary and readings
	response.HRVSummary.HRVReadings = response.HRVReadings
	return &response.HRVSummary, nil
}

3. Body Battery Detailed Implementation

A. Update internal/data/body_battery.go

Add these structures and methods:

// BodyBatteryEvent represents events that impact Body Battery
type BodyBatteryEvent struct {
	EventType              string    `json:"eventType"` // "sleep", "activity", "stress"
	EventStartTimeGMT      time.Time `json:"eventStartTimeGmt"`
	TimezoneOffset         int       `json:"timezoneOffset"`
	DurationInMilliseconds int       `json:"durationInMilliseconds"`
	BodyBatteryImpact      int       `json:"bodyBatteryImpact"`
	FeedbackType           string    `json:"feedbackType"`
	ShortFeedback          string    `json:"shortFeedback"`
}

// DetailedBodyBatteryData represents comprehensive Body Battery data
type DetailedBodyBatteryData struct {
	UserProfilePK          int                    `json:"userProfilePk"`
	CalendarDate           time.Time              `json:"calendarDate"`
	StartTimestampGMT      time.Time              `json:"startTimestampGmt"`
	EndTimestampGMT        time.Time              `json:"endTimestampGmt"`
	StartTimestampLocal    time.Time              `json:"startTimestampLocal"`
	EndTimestampLocal      time.Time              `json:"endTimestampLocal"`
	MaxStressLevel         int                    `json:"maxStressLevel"`
	AvgStressLevel         int                    `json:"avgStressLevel"`
	BodyBatteryValuesArray [][]interface{}        `json:"bodyBatteryValuesArray"`
	StressValuesArray      [][]int                `json:"stressValuesArray"`
	Events                 []BodyBatteryEvent     `json:"bodyBatteryEvents"`
	BaseData
}

func NewDetailedBodyBatteryData() *DetailedBodyBatteryData {
	bb := &DetailedBodyBatteryData{}
	bb.GetFunc = bb.get
	return bb
}

func (d *DetailedBodyBatteryData) get(day time.Time, client *client.Client) (interface{}, error) {
	dateStr := day.Format("2006-01-02")
	
	// Get main Body Battery data
	path1 := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
	data1, err := client.ConnectAPI(path1, "GET", nil, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to get Body Battery stress data: %w", err)
	}

	// Get Body Battery events
	path2 := fmt.Sprintf("/wellness-service/wellness/bodyBattery/%s", dateStr)
	data2, err := client.ConnectAPI(path2, "GET", nil, nil)
	if err != nil {
		// Events might not be available, continue without them
		data2 = []byte("[]")
	}

	var result DetailedBodyBatteryData
	if len(data1) > 0 {
		if err := json.Unmarshal(data1, &result); err != nil {
			return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
		}
	}

	var events []BodyBatteryEvent
	if len(data2) > 0 {
		if err := json.Unmarshal(data2, &events); err == nil {
			result.Events = events
		}
	}

	return &result, nil
}

// GetCurrentLevel returns the most recent Body Battery level
func (d *DetailedBodyBatteryData) GetCurrentLevel() int {
	if len(d.BodyBatteryValuesArray) == 0 {
		return 0
	}
	
	readings := ParseBodyBatteryReadings(d.BodyBatteryValuesArray)
	if len(readings) == 0 {
		return 0
	}
	
	return readings[len(readings)-1].Level
}

// GetDayChange returns the Body Battery change for the day
func (d *DetailedBodyBatteryData) GetDayChange() int {
	readings := ParseBodyBatteryReadings(d.BodyBatteryValuesArray)
	if len(readings) < 2 {
		return 0
	}
	
	return readings[len(readings)-1].Level - readings[0].Level
}

4. Training Status & Load Implementation

A. Create internal/data/training.go

package data

import (
	"encoding/json"
	"fmt"
	"time"

	"go-garth/internal/api/client"
)

// TrainingStatus represents current training status
type TrainingStatus struct {
	CalendarDate          time.Time `json:"calendarDate"`
	TrainingStatusKey     string    `json:"trainingStatusKey"` // "DETRAINING", "RECOVERY", "MAINTAINING", "PRODUCTIVE", "PEAKING", "OVERREACHING", "UNPRODUCTIVE", "NONE"
	TrainingStatusTypeKey string    `json:"trainingStatusTypeKey"`
	TrainingStatusValue   int       `json:"trainingStatusValue"`
	LoadRatio             float64   `json:"loadRatio"`
	BaseData
}

// TrainingLoad represents training load data
type TrainingLoad struct {
	CalendarDate              time.Time `json:"calendarDate"`
	AcuteTrainingLoad         float64   `json:"acuteTrainingLoad"`
	ChronicTrainingLoad       float64   `json:"chronicTrainingLoad"`
	TrainingLoadRatio         float64   `json:"trainingLoadRatio"`
	TrainingEffectAerobic     float64   `json:"trainingEffectAerobic"`
	TrainingEffectAnaerobic   float64   `json:"trainingEffectAnaerobic"`
	BaseData
}

// FitnessAge represents fitness age calculation
type FitnessAge struct {
	FitnessAge       int       `json:"fitnessAge"`
	ChronologicalAge int       `json:"chronologicalAge"`
	VO2MaxRunning    float64   `json:"vo2MaxRunning"`
	LastUpdated      time.Time `json:"lastUpdated"`
}

func NewTrainingStatus() *TrainingStatus {
	ts := &TrainingStatus{}
	ts.GetFunc = ts.get
	return ts
}

func (t *TrainingStatus) get(day time.Time, client *client.Client) (interface{}, error) {
	dateStr := day.Format("2006-01-02")
	path := fmt.Sprintf("/metrics-service/metrics/trainingStatus/%s", dateStr)

	data, err := client.ConnectAPI(path, "GET", nil, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to get training status: %w", err)
	}

	if len(data) == 0 {
		return nil, nil
	}

	var result TrainingStatus
	if err := json.Unmarshal(data, &result); err != nil {
		return nil, fmt.Errorf("failed to parse training status: %w", err)
	}

	return &result, nil
}

func NewTrainingLoad() *TrainingLoad {
	tl := &TrainingLoad{}
	tl.GetFunc = tl.get
	return tl
}

func (t *TrainingLoad) get(day time.Time, client *client.Client) (interface{}, error) {
	dateStr := day.Format("2006-01-02")
	endDate := day.AddDate(0, 0, 6).Format("2006-01-02") // Get week of data
	path := fmt.Sprintf("/metrics-service/metrics/trainingLoad/%s/%s", dateStr, endDate)

	data, err := client.ConnectAPI(path, "GET", nil, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to get training load: %w", err)
	}

	if len(data) == 0 {
		return nil, nil
	}

	var results []TrainingLoad
	if err := json.Unmarshal(data, &results); err != nil {
		return nil, fmt.Errorf("failed to parse training load: %w", err)
	}

	if len(results) == 0 {
		return nil, nil
	}

	return &results[0], nil
}

5. Client Methods Integration

Add these methods to internal/api/client/client.go:

// GetTrainingStatus retrieves current training status
func (c *Client) GetTrainingStatus(date time.Time) (*types.TrainingStatus, error) {
	trainingStatus := data.NewTrainingStatus()
	result, err := trainingStatus.Get(date, c)
	if err != nil {
		return nil, err
	}

	if result == nil {
		return nil, nil
	}

	status, ok := result.(*types.TrainingStatus)
	if !ok {
		return nil, fmt.Errorf("unexpected training status type")
	}

	return status, nil
}

// GetTrainingLoad retrieves training load data
func (c *Client) GetTrainingLoad(date time.Time) (*types.TrainingLoad, error) {
	trainingLoad := data.NewTrainingLoad()
	result, err := trainingLoad.Get(date, c)
	if err != nil {
		return nil, err
	}

	if result == nil {
		return nil, nil
	}

	load, ok := result.(*types.TrainingLoad)
	if !ok {
		return nil, fmt.Errorf("unexpected training load type")
	}

	return load, nil
}

// GetFitnessAge retrieves fitness age calculation
func (c *Client) GetFitnessAge() (*types.FitnessAge, error) {
	path := "/fitness-service/fitness/fitnessAge"
	
	data, err := c.ConnectAPI(path, "GET", nil, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to get fitness age: %w", err)
	}

	if len(data) == 0 {
		return nil, nil
	}

	var fitnessAge types.FitnessAge
	if err := json.Unmarshal(data, &fitnessAge); err != nil {
		return nil, fmt.Errorf("failed to parse fitness age: %w", err)
	}

	fitnessAge.LastUpdated = time.Now()
	return &fitnessAge, nil
}

Implementation Steps

Phase 1: Sleep Data (Week 1)

  1. Create internal/data/sleep_detailed.go
  2. Update internal/types/garmin.go with sleep types
  3. Add client methods
  4. Create tests
  5. Test with real data

Phase 2: HRV Enhancement (Week 2)

  1. Update existing internal/data/hrv.go
  2. Add new HRV types to types file
  3. Enhance client methods
  4. Create comprehensive tests

Phase 3: Body Battery Details (Week 3)

  1. Update internal/data/body_battery.go
  2. Add event tracking
  3. Add convenience methods
  4. Create tests

Phase 4: Training Metrics (Week 4)

  1. Create internal/data/training.go
  2. Add training types
  3. Implement client methods
  4. Create tests and validation

Testing Strategy

Create test files for each new data type:

// Example test structure
func TestDetailedSleepData_Get(t *testing.T) {
    // Mock response from API
    mockResponse := `{
        "dailySleepDTO": {
            "userProfilePk": 12345,
            "calendarDate": "2023-06-15",
            "deepSleepSeconds": 7200,
            "lightSleepSeconds": 14400,
            "remSleepSeconds": 3600,
            "awakeSleepSeconds": 1800
        },
        "sleepMovement": [],
        "sleepLevels": []
    }`
    
    // Create mock client
    server := testutils.MockJSONResponse(http.StatusOK, mockResponse)
    defer server.Close()
    
    // Test implementation
    // ... test logic
}

Error Handling Patterns

For each endpoint, implement consistent error handling:

func (d *DataType) get(day time.Time, client *client.Client) (interface{}, error) {
    data, err := client.ConnectAPI(path, "GET", nil, nil)
    if err != nil {
        // Log the error but don't fail completely
        fmt.Printf("Warning: Failed to get %s data: %v\n", "datatype", err)
        return nil, nil // Return nil data, not error for missing data
    }
    
    if len(data) == 0 {
        return nil, nil // No data available
    }
    
    // Parse and validate
    var result DataType
    if err := json.Unmarshal(data, &result); err != nil {
        return nil, fmt.Errorf("failed to parse %s data: %w", "datatype", err)
    }
    
    return &result, nil
}

Usage Examples

After implementation, users can access the data like this:

// Get detailed sleep data
sleepData, err := client.GetDetailedSleepData(time.Now().AddDate(0, 0, -1))
if err != nil {
    log.Fatal(err)
}
if sleepData != nil {
    fmt.Printf("Sleep efficiency: %.1f%%\n", sleepData.GetSleepEfficiency())
    fmt.Printf("Total sleep: %.1f hours\n", sleepData.GetTotalSleepTime())
}

// Get training status
status, err := client.GetTrainingStatus(time.Now())
if err != nil {
    log.Fatal(err)
}
if status != nil {
    fmt.Printf("Training Status: %s\n", status.TrainingStatusKey)
    fmt.Printf("Load Ratio: %.2f\n", status.LoadRatio)
}

This implementation guide provides a comprehensive foundation for adding the most requested Garmin Connect API endpoints to your Go client.