# 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): ```go // 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: ```go 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: ```go // 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**: ```go 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: ```bash # 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: ```go // 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