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
- Open
internal/types/garmin.go - Find the existing
VO2MaxDatastruct (around line 120) - Replace it with the enhanced version provided above
- Add the new
VO2MaxEntryandVO2MaxProfilestructs
Step 2: Create VO2 Max Data Handler
- Create the new file
internal/data/vo2max.go - Copy the entire content provided above
- Make sure imports are correct
Step 3: Update Client Methods
- Open
internal/api/client/client.go - Find the existing
GetVO2MaxDatamethod - Replace it with the improved version
- Add the new
GetCurrentVO2Maxmethod
Step 4: Create Tests
- Create
internal/data/vo2max_test.go - Add the test content provided above
- 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:
-
Primary:
/userprofile-service/userprofile/user-settings- Contains current VO2 max values for running and cycling
- Most reliable source of VO2 max data
-
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
- Unit Tests: Test the data parsing and type conversion logic
- Integration Tests: Test with real Garmin Connect API calls (if credentials available)
- 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