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

12 KiB

VO2 Max Implementation Guide

Overview

This guide will help you implement VO2 max data retrieval in the go-garth Garmin Connect API client. VO2 max is a fitness metric that represents the maximum amount of oxygen consumption during exercise.

Background

Based on analysis of existing Garmin Connect libraries:

  • Python garth: Retrieves VO2 max via UserSettings.get().user_data.vo_2_max_running/cycling
  • Go garmin-connect: Retrieves via PersonalInformation().BiometricProfile.VO2Max/VO2MaxCycling

Our implementation will follow the Python approach since we already have a UserSettings structure.

Files to Modify

1. Update internal/types/garmin.go

What to do: Add enhanced VO2 max types to support comprehensive VO2 max data.

Location: Add these structs to the file (around line 120, after the existing VO2MaxData struct):

// Replace the existing VO2MaxData struct with this enhanced version
type VO2MaxData struct {
	Date          time.Time `json:"calendarDate"`
	VO2MaxRunning *float64  `json:"vo2MaxRunning"`
	VO2MaxCycling *float64  `json:"vo2MaxCycling"`
	UserProfilePK int       `json:"userProfilePk"`
}

// Add these new structs
type VO2MaxEntry struct {
	Value        float64   `json:"value"`
	ActivityType string    `json:"activityType"` // "running" or "cycling"
	Date         time.Time `json:"date"`
	Source       string    `json:"source"` // "user_settings", "activity", etc.
}

type VO2MaxProfile struct {
	UserProfilePK int              `json:"userProfilePk"`
	Running       *VO2MaxEntry     `json:"running"`
	Cycling       *VO2MaxEntry     `json:"cycling"`
	History       []VO2MaxEntry    `json:"history,omitempty"`
	LastUpdated   time.Time        `json:"lastUpdated"`
}

2. Create internal/data/vo2max.go

What to do: Create a new file to handle VO2 max data retrieval.

Create new file with this content:

package data

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

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

// VO2MaxData implements the Data interface for VO2 max retrieval
type VO2MaxData struct {
	BaseData
}

// NewVO2MaxData creates a new VO2MaxData instance
func NewVO2MaxData() *VO2MaxData {
	vo2 := &VO2MaxData{}
	vo2.GetFunc = vo2.get
	return vo2
}

// get implements the specific VO2 max data retrieval logic
func (v *VO2MaxData) get(day time.Time, c *client.Client) (interface{}, error) {
	// Primary approach: Get from user settings (most reliable)
	settings, err := c.GetUserSettings()
	if err != nil {
		return nil, fmt.Errorf("failed to get user settings: %w", err)
	}

	// Extract VO2 max data from user settings
	vo2Profile := &types.VO2MaxProfile{
		UserProfilePK: settings.ID,
		LastUpdated:   time.Now(),
	}

	// Add running VO2 max if available
	if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
		vo2Profile.Running = &types.VO2MaxEntry{
			Value:        *settings.UserData.VO2MaxRunning,
			ActivityType: "running",
			Date:         day,
			Source:       "user_settings",
		}
	}

	// Add cycling VO2 max if available
	if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
		vo2Profile.Cycling = &types.VO2MaxEntry{
			Value:        *settings.UserData.VO2MaxCycling,
			ActivityType: "cycling",
			Date:         day,
			Source:       "user_settings",
		}
	}

	// If no VO2 max data found, still return valid empty profile
	return vo2Profile, nil
}

// List implements concurrent fetching for multiple days
// Note: VO2 max typically doesn't change daily, so this returns the same values
func (v *VO2MaxData) List(end time.Time, days int, c *client.Client, maxWorkers int) ([]interface{}, []error) {
	// For VO2 max, we want current values from user settings
	vo2Data, err := v.get(end, c)
	if err != nil {
		return nil, []error{err}
	}

	// Return the same VO2 max data for all requested days
	results := make([]interface{}, days)
	for i := 0; i < days; i++ {
		results[i] = vo2Data
	}

	return results, nil
}

// GetCurrentVO2Max is a convenience method to get current VO2 max values
func GetCurrentVO2Max(c *client.Client) (*types.VO2MaxProfile, error) {
	vo2Data := NewVO2MaxData()
	result, err := vo2Data.get(time.Now(), c)
	if err != nil {
		return nil, err
	}

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

	return vo2Profile, nil
}

3. Update internal/api/client/client.go

What to do: Improve the existing GetVO2MaxData method and add convenience methods.

Find the existing GetVO2MaxData method (around line 450) and replace it with:

// GetVO2MaxData retrieves VO2 max data using the modern approach via user settings
func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]types.VO2MaxData, error) {
	// Get user settings which contains current VO2 max values
	settings, err := c.GetUserSettings()
	if err != nil {
		return nil, fmt.Errorf("failed to get user settings: %w", err)
	}

	// Create VO2MaxData for the date range
	var results []types.VO2MaxData
	current := startDate
	for !current.After(endDate) {
		vo2Data := types.VO2MaxData{
			Date:          current,
			UserProfilePK: settings.ID,
		}

		// Set VO2 max values if available
		if settings.UserData.VO2MaxRunning != nil {
			vo2Data.VO2MaxRunning = settings.UserData.VO2MaxRunning
		}
		if settings.UserData.VO2MaxCycling != nil {
			vo2Data.VO2MaxCycling = settings.UserData.VO2MaxCycling
		}

		results = append(results, vo2Data)
		current = current.AddDate(0, 0, 1)
	}

	return results, nil
}

// GetCurrentVO2Max retrieves the current VO2 max values from user profile
func (c *Client) GetCurrentVO2Max() (*types.VO2MaxProfile, error) {
	settings, err := c.GetUserSettings()
	if err != nil {
		return nil, fmt.Errorf("failed to get user settings: %w", err)
	}

	profile := &types.VO2MaxProfile{
		UserProfilePK: settings.ID,
		LastUpdated:   time.Now(),
	}

	// Add running VO2 max if available
	if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
		profile.Running = &types.VO2MaxEntry{
			Value:        *settings.UserData.VO2MaxRunning,
			ActivityType: "running",
			Date:         time.Now(),
			Source:       "user_settings",
		}
	}

	// Add cycling VO2 max if available
	if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
		profile.Cycling = &types.VO2MaxEntry{
			Value:        *settings.UserData.VO2MaxCycling,
			ActivityType: "cycling",
			Date:         time.Now(),
			Source:       "user_settings",
		}
	}

	return profile, nil
}

4. Create internal/data/vo2max_test.go

What to do: Create tests to ensure the VO2 max functionality works correctly.

Create new file:

package data

import (
	"testing"
	"time"

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

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

// MockClient for testing
type MockClient struct {
	mock.Mock
}

func (m *MockClient) GetUserSettings() (*client.UserSettings, error) {
	args := m.Called()
	return args.Get(0).(*client.UserSettings), args.Error(1)
}

func TestVO2MaxData_Get(t *testing.T) {
	// Setup mock client
	mockClient := &MockClient{}
	
	// Mock user settings with VO2 max data
	runningVO2 := 45.0
	cyclingVO2 := 50.0
	mockSettings := &client.UserSettings{
		ID: 12345,
		UserData: client.UserData{
			VO2MaxRunning: &runningVO2,
			VO2MaxCycling: &cyclingVO2,
		},
	}
	
	mockClient.On("GetUserSettings").Return(mockSettings, nil)
	
	// Test the VO2MaxData.get method
	vo2Data := NewVO2MaxData()
	result, err := vo2Data.get(time.Now(), mockClient)
	
	// Assertions
	assert.NoError(t, err)
	assert.NotNil(t, result)
	
	profile, ok := result.(*types.VO2MaxProfile)
	assert.True(t, ok)
	assert.Equal(t, 12345, profile.UserProfilePK)
	assert.NotNil(t, profile.Running)
	assert.Equal(t, 45.0, profile.Running.Value)
	assert.Equal(t, "running", profile.Running.ActivityType)
	assert.NotNil(t, profile.Cycling)
	assert.Equal(t, 50.0, profile.Cycling.Value)
	assert.Equal(t, "cycling", profile.Cycling.ActivityType)
}

func TestGetCurrentVO2Max(t *testing.T) {
	// Similar test for the convenience function
	mockClient := &MockClient{}
	
	runningVO2 := 48.0
	mockSettings := &client.UserSettings{
		ID: 67890,
		UserData: client.UserData{
			VO2MaxRunning: &runningVO2,
			VO2MaxCycling: nil, // No cycling data
		},
	}
	
	mockClient.On("GetUserSettings").Return(mockSettings, nil)
	
	result, err := GetCurrentVO2Max(mockClient)
	
	assert.NoError(t, err)
	assert.NotNil(t, result)
	assert.Equal(t, 67890, result.UserProfilePK)
	assert.NotNil(t, result.Running)
	assert.Equal(t, 48.0, result.Running.Value)
	assert.Nil(t, result.Cycling) // Should be nil since no cycling data
}

Implementation Steps

Step 1: Update Types

  1. Open internal/types/garmin.go
  2. Find the existing VO2MaxData struct (around line 120)
  3. Replace it with the enhanced version provided above
  4. Add the new VO2MaxEntry and VO2MaxProfile structs

Step 2: Create VO2 Max Data Handler

  1. Create the new file internal/data/vo2max.go
  2. Copy the entire content provided above
  3. Make sure imports are correct

Step 3: Update Client Methods

  1. Open internal/api/client/client.go
  2. Find the existing GetVO2MaxData method
  3. Replace it with the improved version
  4. Add the new GetCurrentVO2Max method

Step 4: Create Tests

  1. Create internal/data/vo2max_test.go
  2. Add the test content provided above
  3. Install testify if not already available: go get github.com/stretchr/testify

Step 5: Verify Implementation

Run these commands to verify everything works:

# Run tests
go test ./internal/data/

# Build the project
go build ./...

# Test the functionality (if you have credentials set up)
go test -v ./internal/api/client/ -run TestClient_Login_Functional

API Endpoints Used

The implementation uses these Garmin Connect API endpoints:

  1. Primary: /userprofile-service/userprofile/user-settings

    • Contains current VO2 max values for running and cycling
    • Most reliable source of VO2 max data
  2. Alternative: /wellness-service/wellness/daily/vo2max/{date}

    • May contain historical VO2 max data
    • Not always available or accessible

Usage Examples

After implementation, developers can use the VO2 max functionality like this:

// Get current VO2 max values
profile, err := client.GetCurrentVO2Max()
if err != nil {
    log.Fatal(err)
}

if profile.Running != nil {
    fmt.Printf("Running VO2 Max: %.1f\n", profile.Running.Value)
}
if profile.Cycling != nil {
    fmt.Printf("Cycling VO2 Max: %.1f\n", profile.Cycling.Value)
}

// Get VO2 max data for a date range
start := time.Now().AddDate(0, 0, -7)
end := time.Now()
vo2Data, err := client.GetVO2MaxData(start, end)
if err != nil {
    log.Fatal(err)
}

for _, data := range vo2Data {
    fmt.Printf("Date: %s, Running: %v, Cycling: %v\n", 
        data.Date.Format("2006-01-02"), 
        data.VO2MaxRunning, 
        data.VO2MaxCycling)
}

Common Issues and Solutions

Issue 1: "GetUserSettings method not found"

Solution: Make sure you've properly implemented the GetUserSettings method in internal/api/client/settings.go. The method already exists but verify it's working correctly.

Issue 2: "VO2 max values are nil"

Solution: This is normal if the user hasn't done any activities that would calculate VO2 max. The code handles this gracefully by checking for nil values.

Issue 3: Import errors

Solution: Run go mod tidy to ensure all dependencies are properly managed.

Issue 4: Test failures

Solution: Make sure you have the testify package installed and that the mock interfaces match the actual client interface.

Testing Strategy

  1. Unit Tests: Test the data parsing and type conversion logic
  2. Integration Tests: Test with real Garmin Connect API calls (if credentials available)
  3. Mock Tests: Test error handling and edge cases with mocked responses

Notes for Code Review

When reviewing this implementation:

  • Check that nil pointer dereferences are avoided
  • Verify proper error handling throughout
  • Ensure the API follows existing patterns in the codebase
  • Confirm that the VO2 max data structure matches Garmin's API response format
  • Test with users who have both running and cycling VO2 max data
  • Test with users who have no VO2 max data