mirror of
https://github.com/sstent/go-garth.git
synced 2026-01-25 16:42:28 +00:00
426 lines
12 KiB
Markdown
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
|