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

426 lines
12 KiB
Markdown

# 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