mirror of
https://github.com/sstent/go-garth.git
synced 2026-02-06 22:41:38 +00:00
sync - build broken
This commit is contained in:
@@ -14,9 +14,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/errors"
|
||||
"go-garth/internal/auth/sso"
|
||||
"go-garth/internal/types"
|
||||
"go-garth/internal/errors"
|
||||
types "go-garth/internal/models/types"
|
||||
shared "go-garth/shared/interfaces"
|
||||
)
|
||||
|
||||
// Client represents the Garmin Connect API client
|
||||
@@ -29,6 +30,80 @@ type Client struct {
|
||||
OAuth2Token *types.OAuth2Token
|
||||
}
|
||||
|
||||
// Verify that Client implements shared.APIClient
|
||||
var _ shared.APIClient = (*Client)(nil)
|
||||
|
||||
// GetUsername returns the authenticated username
|
||||
func (c *Client) GetUsername() string {
|
||||
return c.Username
|
||||
}
|
||||
|
||||
// GetUserSettings retrieves the current user's settings
|
||||
func (c *Client) GetUserSettings() (*types.UserSettings, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
host := c.Domain
|
||||
if !strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
host = "connectapi." + c.Domain
|
||||
}
|
||||
settingsURL := fmt.Sprintf("%s://%s/userprofile-service/userprofile/user-settings", scheme, host)
|
||||
|
||||
req, err := http.NewRequest("GET", settingsURL, nil)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create user settings request",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.AuthToken)
|
||||
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to get user settings",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: string(body),
|
||||
GarthError: errors.GarthError{
|
||||
Message: "User settings request failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var settings types.UserSettings
|
||||
if err := json.NewDecoder(resp.Body).Decode(&settings); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to parse user settings",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// NewClient creates a new Garmin Connect client
|
||||
func NewClient(domain string) (*Client, error) {
|
||||
if domain == "" {
|
||||
@@ -145,7 +220,11 @@ func (c *Client) GetUserProfile() (*types.UserProfile, error) {
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
profileURL := fmt.Sprintf("%s://connectapi.%s/userprofile-service/socialProfile", scheme, c.Domain)
|
||||
host := c.Domain
|
||||
if !strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
host = "connectapi." + c.Domain
|
||||
}
|
||||
profileURL := fmt.Sprintf("%s://%s/userprofile-service/socialProfile", scheme, host)
|
||||
|
||||
req, err := http.NewRequest("GET", profileURL, nil)
|
||||
if err != nil {
|
||||
@@ -464,71 +543,71 @@ func (c *Client) GetCaloriesData(startDate, endDate time.Time) ([]types.Calories
|
||||
return nil, fmt.Errorf("GetCaloriesData not implemented")
|
||||
}
|
||||
|
||||
|
||||
// GetVO2MaxData retrieves VO2 max data using the modern approach via user settings
|
||||
func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]types.VO2MaxData, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("startDate", startDate.Format("2006-01-02"))
|
||||
params.Add("endDate", endDate.Format("2006-01-02"))
|
||||
|
||||
vo2MaxURL := fmt.Sprintf("%s://connectapi.%s/wellness-service/wellness/daily/vo2max?%s", scheme, c.Domain, params.Encode())
|
||||
|
||||
req, err := http.NewRequest("GET", vo2MaxURL, nil)
|
||||
// Get user settings which contains current VO2 max values
|
||||
settings, err := c.GetUserSettings()
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create VO2 max request",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.AuthToken)
|
||||
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
|
||||
// Create VO2MaxData for the date range
|
||||
var results []types.VO2MaxData
|
||||
current := startDate
|
||||
for !current.After(endDate) {
|
||||
vo2Data := types.VO2MaxData{
|
||||
Date: current,
|
||||
UserProfilePK: settings.ID,
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
// 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, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to get VO2 max data",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: string(body),
|
||||
GarthError: errors.GarthError{
|
||||
Message: "VO2 max request failed",
|
||||
},
|
||||
},
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
var vo2MaxData []types.VO2MaxData
|
||||
if err := json.NewDecoder(resp.Body).Decode(&vo2MaxData); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to parse VO2 max data",
|
||||
Cause: err,
|
||||
},
|
||||
// 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 vo2MaxData, nil
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// GetHeartRateZones retrieves heart rate zone data
|
||||
@@ -691,6 +770,163 @@ func (c *Client) SaveSession(filename string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDetailedSleepData retrieves comprehensive sleep data for a date
|
||||
func (c *Client) GetDetailedSleepData(date time.Time) (*types.DetailedSleepData, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?date=%s&nonSleepBufferMinutes=60",
|
||||
c.Username, dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get detailed sleep data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
DailySleepDTO *types.DetailedSleepData `json:"dailySleepDTO"`
|
||||
SleepMovement []types.SleepMovement `json:"sleepMovement"`
|
||||
RemSleepData bool `json:"remSleepData"`
|
||||
SleepLevels []types.SleepLevel `json:"sleepLevels"`
|
||||
SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"`
|
||||
RestlessMomentsCount int `json:"restlessMomentsCount"`
|
||||
WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"`
|
||||
WellnessEpochSPO2DataDTOList []interface{} `json:"wellnessEpochSPO2DataDTOList"`
|
||||
WellnessEpochRespirationDataDTOList []interface{} `json:"wellnessEpochRespirationDataDTOList"`
|
||||
SleepStress interface{} `json:"sleepStress"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse detailed sleep response: %w", err)
|
||||
}
|
||||
|
||||
if response.DailySleepDTO == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Populate additional data
|
||||
response.DailySleepDTO.SleepMovement = response.SleepMovement
|
||||
response.DailySleepDTO.SleepLevels = response.SleepLevels
|
||||
|
||||
return response.DailySleepDTO, nil
|
||||
}
|
||||
|
||||
// GetDailyHRVData retrieves comprehensive daily HRV data for a date
|
||||
func (c *Client) GetDailyHRVData(date time.Time) (*types.DailyHRVData, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailyHrvData/%s?date=%s",
|
||||
c.Username, dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get HRV data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
HRVSummary types.DailyHRVData `json:"hrvSummary"`
|
||||
HRVReadings []types.HRVReading `json:"hrvReadings"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse HRV response: %w", err)
|
||||
}
|
||||
|
||||
// Combine summary and readings
|
||||
response.HRVSummary.HRVReadings = response.HRVReadings
|
||||
return &response.HRVSummary, nil
|
||||
}
|
||||
|
||||
// GetDetailedBodyBatteryData retrieves comprehensive Body Battery data for a date
|
||||
func (c *Client) GetDetailedBodyBatteryData(date time.Time) (*types.DetailedBodyBatteryData, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
|
||||
// Get main Body Battery data
|
||||
path1 := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
|
||||
data1, err := c.ConnectAPI(path1, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Body Battery stress data: %w", err)
|
||||
}
|
||||
|
||||
// Get Body Battery events
|
||||
path2 := fmt.Sprintf("/wellness-service/wellness/bodyBattery/%s", dateStr)
|
||||
data2, err := c.ConnectAPI(path2, "GET", nil, nil)
|
||||
if err != nil {
|
||||
// Events might not be available, continue without them
|
||||
data2 = []byte("[]")
|
||||
}
|
||||
|
||||
var result types.DetailedBodyBatteryData
|
||||
if len(data1) > 0 {
|
||||
if err := json.Unmarshal(data1, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var events []types.BodyBatteryEvent
|
||||
if len(data2) > 0 {
|
||||
if err := json.Unmarshal(data2, &events); err == nil {
|
||||
result.Events = events
|
||||
}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetTrainingStatus retrieves current training status
|
||||
func (c *Client) GetTrainingStatus(date time.Time) (*types.TrainingStatus, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingStatus/%s", dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training status: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result types.TrainingStatus
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training status: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetTrainingLoad retrieves training load data
|
||||
func (c *Client) GetTrainingLoad(date time.Time) (*types.TrainingLoad, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
endDate := date.AddDate(0, 0, 6).Format("2006-01-02") // Get week of data
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingLoad/%s/%s", dateStr, endDate)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training load: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []types.TrainingLoad
|
||||
if err := json.Unmarshal(data, &results); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training load: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &results[0], nil
|
||||
}
|
||||
|
||||
// LoadSession loads a session from a file
|
||||
func (c *Client) LoadSession(filename string) error {
|
||||
data, err := os.ReadFile(filename)
|
||||
@@ -724,4 +960,4 @@ func (c *Client) LoadSession(filename string) error {
|
||||
func (c *Client) RefreshSession() error {
|
||||
// TODO: Implement token refresh logic
|
||||
return fmt.Errorf("RefreshSession not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ func TestClient_GetUserProfile(t *testing.T) {
|
||||
u, _ := url.Parse(server.URL)
|
||||
c, err := client.NewClient(u.Host)
|
||||
require.NoError(t, err)
|
||||
c.Domain = u.Host
|
||||
require.NoError(t, err)
|
||||
c.HTTPClient = &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
|
||||
18
internal/api/client_interface.go
Normal file
18
internal/api/client_interface.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
types "go-garth/internal/models/types"
|
||||
"go-garth/internal/users"
|
||||
)
|
||||
|
||||
// APIClient defines the interface for making API calls
|
||||
type APIClient interface {
|
||||
ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error)
|
||||
GetUserSettings() (*users.UserSettings, error)
|
||||
GetUserProfile() (*types.UserProfile, error)
|
||||
GetWellnessData(startDate, endDate time.Time) ([]types.WellnessData, error)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/types"
|
||||
"go-garth/internal/models/types"
|
||||
"go-garth/internal/utils"
|
||||
)
|
||||
|
||||
@@ -159,4 +159,4 @@ func ExchangeToken(oauth1Token *types.OAuth1Token) (*types.OAuth2Token, error) {
|
||||
}
|
||||
|
||||
return &oauth2Token, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go-garth/internal/auth/oauth"
|
||||
"go-garth/internal/types"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
"go-garth/internal/utils"
|
||||
)
|
||||
|
||||
// Data defines the interface for Garmin Connect data types.
|
||||
// Concrete data types (BodyBattery, HRV, Sleep, etc.) must implement this interface.
|
||||
//
|
||||
// The Get method retrieves data for a single day.
|
||||
// The List method concurrently retrieves data for a range of days.
|
||||
type Data interface {
|
||||
Get(day time.Time, c *client.Client) (interface{}, error)
|
||||
List(end time.Time, days int, c *client.Client, maxWorkers int) ([]interface{}, error)
|
||||
}
|
||||
|
||||
// BaseData provides a reusable implementation for data types to embed.
|
||||
// It handles the concurrent List() implementation while allowing concrete types
|
||||
// to focus on implementing the Get() method for their specific data structure.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// type BodyBatteryData struct {
|
||||
// data.BaseData
|
||||
// // ... additional fields
|
||||
// }
|
||||
//
|
||||
// func NewBodyBatteryData() *BodyBatteryData {
|
||||
// bb := &BodyBatteryData{}
|
||||
// bb.GetFunc = bb.get // Assign the concrete Get implementation
|
||||
// return bb
|
||||
// }
|
||||
//
|
||||
// func (bb *BodyBatteryData) get(day time.Time, c *client.Client) (interface{}, error) {
|
||||
// // Implementation specific to body battery data
|
||||
// }
|
||||
type BaseData struct {
|
||||
// GetFunc must be set by concrete types to implement the Get method.
|
||||
// This function pointer allows BaseData to call the concrete implementation.
|
||||
GetFunc func(day time.Time, c *client.Client) (interface{}, error)
|
||||
}
|
||||
|
||||
// Get implements the Data interface by calling the configured GetFunc.
|
||||
// Returns an error if GetFunc is not set.
|
||||
func (b *BaseData) Get(day time.Time, c *client.Client) (interface{}, error) {
|
||||
if b.GetFunc == nil {
|
||||
return nil, errors.New("GetFunc not implemented for this data type")
|
||||
}
|
||||
return b.GetFunc(day, c)
|
||||
}
|
||||
|
||||
// List implements concurrent data fetching using a worker pool pattern.
|
||||
// This method efficiently retrieves data for multiple days by distributing
|
||||
// work across a configurable number of workers (goroutines).
|
||||
//
|
||||
// Parameters:
|
||||
//
|
||||
// end: The end date of the range (inclusive)
|
||||
// days: Number of days to fetch (going backwards from end date)
|
||||
// c: Client instance for API access
|
||||
// maxWorkers: Maximum concurrent workers (minimum 1)
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// []interface{}: Slice of results (order matches date range)
|
||||
// []error: Slice of errors encountered during processing
|
||||
func (b *BaseData) List(end time.Time, days int, c *client.Client, maxWorkers int) ([]interface{}, []error) {
|
||||
if maxWorkers < 1 {
|
||||
maxWorkers = 10 // Match Python's MAX_WORKERS
|
||||
}
|
||||
|
||||
dates := utils.DateRange(end, days)
|
||||
|
||||
// Define result type for channel
|
||||
type result struct {
|
||||
data interface{}
|
||||
err error
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
workCh := make(chan time.Time, days)
|
||||
resultsCh := make(chan result, days)
|
||||
|
||||
// Worker function
|
||||
worker := func() {
|
||||
defer wg.Done()
|
||||
for date := range workCh {
|
||||
data, err := b.Get(date, c)
|
||||
resultsCh <- result{data: data, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// Start workers
|
||||
wg.Add(maxWorkers)
|
||||
for i := 0; i < maxWorkers; i++ {
|
||||
go worker()
|
||||
}
|
||||
|
||||
// Send work
|
||||
go func() {
|
||||
for _, date := range dates {
|
||||
workCh <- date
|
||||
}
|
||||
close(workCh)
|
||||
}()
|
||||
|
||||
// Close results channel when workers are done
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultsCh)
|
||||
}()
|
||||
|
||||
var results []interface{}
|
||||
var errs []error
|
||||
|
||||
for r := range resultsCh {
|
||||
if r.err != nil {
|
||||
errs = append(errs, r.err)
|
||||
} else if r.data != nil {
|
||||
results = append(results, r.data)
|
||||
}
|
||||
}
|
||||
|
||||
return results, errs
|
||||
}
|
||||
@@ -6,82 +6,31 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// DailyBodyBatteryStress represents complete daily Body Battery and stress data
|
||||
type DailyBodyBatteryStress struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
StartTimestampGMT time.Time `json:"startTimestampGmt"`
|
||||
EndTimestampGMT time.Time `json:"endTimestampGmt"`
|
||||
StartTimestampLocal time.Time `json:"startTimestampLocal"`
|
||||
EndTimestampLocal time.Time `json:"endTimestampLocal"`
|
||||
MaxStressLevel int `json:"maxStressLevel"`
|
||||
AvgStressLevel int `json:"avgStressLevel"`
|
||||
StressChartValueOffset int `json:"stressChartValueOffset"`
|
||||
StressChartYAxisOrigin int `json:"stressChartYAxisOrigin"`
|
||||
StressValuesArray [][]int `json:"stressValuesArray"`
|
||||
BodyBatteryValuesArray [][]any `json:"bodyBatteryValuesArray"`
|
||||
}
|
||||
|
||||
// BodyBatteryEvent represents a Body Battery impact event
|
||||
type BodyBatteryEvent struct {
|
||||
EventType string `json:"eventType"`
|
||||
EventStartTimeGMT time.Time `json:"eventStartTimeGmt"`
|
||||
TimezoneOffset int `json:"timezoneOffset"`
|
||||
DurationInMilliseconds int `json:"durationInMilliseconds"`
|
||||
BodyBatteryImpact int `json:"bodyBatteryImpact"`
|
||||
FeedbackType string `json:"feedbackType"`
|
||||
ShortFeedback string `json:"shortFeedback"`
|
||||
}
|
||||
|
||||
// BodyBatteryData represents legacy Body Battery events data
|
||||
type BodyBatteryData struct {
|
||||
Event *BodyBatteryEvent `json:"event"`
|
||||
ActivityName string `json:"activityName"`
|
||||
ActivityType string `json:"activityType"`
|
||||
ActivityID string `json:"activityId"`
|
||||
AverageStress float64 `json:"averageStress"`
|
||||
StressValuesArray [][]int `json:"stressValuesArray"`
|
||||
BodyBatteryValuesArray [][]any `json:"bodyBatteryValuesArray"`
|
||||
}
|
||||
|
||||
// BodyBatteryReading represents an individual Body Battery reading
|
||||
type BodyBatteryReading struct {
|
||||
Timestamp int `json:"timestamp"`
|
||||
Status string `json:"status"`
|
||||
Level int `json:"level"`
|
||||
Version float64 `json:"version"`
|
||||
}
|
||||
|
||||
// StressReading represents an individual stress reading
|
||||
type StressReading struct {
|
||||
Timestamp int `json:"timestamp"`
|
||||
StressLevel int `json:"stressLevel"`
|
||||
}
|
||||
|
||||
// ParseBodyBatteryReadings converts body battery values array to structured readings
|
||||
func ParseBodyBatteryReadings(valuesArray [][]any) []BodyBatteryReading {
|
||||
readings := make([]BodyBatteryReading, 0)
|
||||
func ParseBodyBatteryReadings(valuesArray [][]any) []types.BodyBatteryReading {
|
||||
readings := make([]types.BodyBatteryReading, 0)
|
||||
for _, values := range valuesArray {
|
||||
if len(values) < 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
timestamp, ok1 := values[0].(int)
|
||||
timestamp, ok1 := values[0].(float64)
|
||||
status, ok2 := values[1].(string)
|
||||
level, ok3 := values[2].(int)
|
||||
level, ok3 := values[2].(float64)
|
||||
version, ok4 := values[3].(float64)
|
||||
|
||||
if !ok1 || !ok2 || !ok3 || !ok4 {
|
||||
continue
|
||||
}
|
||||
|
||||
readings = append(readings, BodyBatteryReading{
|
||||
Timestamp: timestamp,
|
||||
readings = append(readings, types.BodyBatteryReading{
|
||||
Timestamp: int(timestamp),
|
||||
Status: status,
|
||||
Level: level,
|
||||
Level: int(level),
|
||||
Version: version,
|
||||
})
|
||||
}
|
||||
@@ -91,48 +40,61 @@ func ParseBodyBatteryReadings(valuesArray [][]any) []BodyBatteryReading {
|
||||
return readings
|
||||
}
|
||||
|
||||
// ParseStressReadings converts stress values array to structured readings
|
||||
func ParseStressReadings(valuesArray [][]int) []StressReading {
|
||||
readings := make([]StressReading, 0)
|
||||
for _, values := range valuesArray {
|
||||
if len(values) != 2 {
|
||||
continue
|
||||
}
|
||||
readings = append(readings, StressReading{
|
||||
Timestamp: values[0],
|
||||
StressLevel: values[1],
|
||||
})
|
||||
}
|
||||
sort.Slice(readings, func(i, j int) bool {
|
||||
return readings[i].Timestamp < readings[j].Timestamp
|
||||
})
|
||||
return readings
|
||||
}
|
||||
|
||||
// Get implements the Data interface for DailyBodyBatteryStress
|
||||
func (d *DailyBodyBatteryStress) Get(day time.Time, client *client.Client) (any, error) {
|
||||
func (d *types.DetailedBodyBatteryData) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
|
||||
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
// Get main Body Battery data
|
||||
path1 := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
|
||||
data1, err := c.ConnectAPI(path1, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to get Body Battery stress data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
// Get Body Battery events
|
||||
path2 := fmt.Sprintf("/wellness-service/wellness/bodyBattery/%s", dateStr)
|
||||
data2, err := c.ConnectAPI(path2, "GET", nil, nil)
|
||||
if err != nil {
|
||||
// Events might not be available, continue without them
|
||||
data2 = []byte("[]")
|
||||
}
|
||||
|
||||
var result DailyBodyBatteryStress
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
var result types.DetailedBodyBatteryData
|
||||
if len(data1) > 0 {
|
||||
if err := json.Unmarshal(data1, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var events []types.BodyBatteryEvent
|
||||
if len(data2) > 0 {
|
||||
if err := json.Unmarshal(data2, &events); err == nil {
|
||||
result.Events = events
|
||||
}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
func (d *DailyBodyBatteryStress) List(end time.Time, days int, client *client.Client, maxWorkers int) ([]any, error) {
|
||||
// Implementation to be added
|
||||
return []any{}, nil
|
||||
// GetCurrentLevel returns the most recent Body Battery level
|
||||
func (d *types.DetailedBodyBatteryData) GetCurrentLevel() int {
|
||||
if len(d.BodyBatteryValuesArray) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
readings := ParseBodyBatteryReadings(d.BodyBatteryValuesArray)
|
||||
if len(readings) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return readings[len(readings)-1].Level
|
||||
}
|
||||
|
||||
// GetDayChange returns the Body Battery change for the day
|
||||
func (d *types.DetailedBodyBatteryData) GetDayChange() int {
|
||||
readings := ParseBodyBatteryReadings(d.BodyBatteryValuesArray)
|
||||
if len(readings) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return readings[len(readings)-1].Level - readings[0].Level
|
||||
}
|
||||
|
||||
@@ -2,153 +2,46 @@ package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
"go-garth/internal/utils"
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// HRVSummary represents Heart Rate Variability summary data
|
||||
type HRVSummary struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
StartTimestampGMT time.Time `json:"startTimestampGmt"`
|
||||
EndTimestampGMT time.Time `json:"endTimestampGmt"`
|
||||
WeeklyAvg float64 `json:"weeklyAvg"`
|
||||
LastNightAvg float64 `json:"lastNightAvg"`
|
||||
Baseline float64 `json:"baseline"`
|
||||
// Update the existing get method in hrv.go
|
||||
func (h *types.DailyHRVData) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailyHrvData/%s?date=%s",
|
||||
c.GetUsername(), dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get HRV data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
HRVSummary types.DailyHRVData `json:"hrvSummary"`
|
||||
HRVReadings []types.HRVReading `json:"hrvReadings"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse HRV response: %w", err)
|
||||
}
|
||||
|
||||
// Combine summary and readings
|
||||
response.HRVSummary.HRVReadings = response.HRVReadings
|
||||
return &response.HRVSummary, nil
|
||||
}
|
||||
|
||||
// HRVReading represents an individual HRV reading
|
||||
type HRVReading struct {
|
||||
Timestamp int `json:"timestamp"`
|
||||
StressLevel int `json:"stressLevel"`
|
||||
HeartRate int `json:"heartRate"`
|
||||
RRInterval int `json:"rrInterval"`
|
||||
Status string `json:"status"`
|
||||
SignalQuality float64 `json:"signalQuality"`
|
||||
}
|
||||
|
||||
// TimestampAsTime converts the reading timestamp to time.Time using timeutils
|
||||
func (r *HRVReading) TimestampAsTime() time.Time {
|
||||
return utils.ParseTimestamp(r.Timestamp)
|
||||
}
|
||||
|
||||
// RRSeconds converts the RR interval to seconds
|
||||
func (r *HRVReading) RRSeconds() float64 {
|
||||
return float64(r.RRInterval) / 1000.0
|
||||
}
|
||||
|
||||
// HRVData represents complete HRV data
|
||||
type HRVData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
HRVSummary HRVSummary `json:"hrvSummary"`
|
||||
HRVReadings []HRVReading `json:"hrvReadings"`
|
||||
BaseData
|
||||
}
|
||||
|
||||
// Validate ensures HRVSummary fields meet requirements
|
||||
func (h *HRVSummary) Validate() error {
|
||||
if h.WeeklyAvg < 0 {
|
||||
return errors.New("WeeklyAvg must be non-negative")
|
||||
}
|
||||
if h.LastNightAvg < 0 {
|
||||
return errors.New("LastNightAvg must be non-negative")
|
||||
}
|
||||
if h.Baseline < 0 {
|
||||
return errors.New("Baseline must be non-negative")
|
||||
}
|
||||
if h.CalendarDate.IsZero() {
|
||||
return errors.New("CalendarDate must be set")
|
||||
}
|
||||
if h.StartTimestampGMT.IsZero() || h.EndTimestampGMT.IsZero() {
|
||||
return errors.New("Timestamps must be set")
|
||||
}
|
||||
if h.EndTimestampGMT.Before(h.StartTimestampGMT) {
|
||||
return errors.New("EndTimestampGMT must be after StartTimestampGMT")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate ensures HRVReading fields meet requirements
|
||||
func (r *HRVReading) Validate() error {
|
||||
if r.StressLevel < 0 || r.StressLevel > 100 {
|
||||
return fmt.Errorf("StressLevel must be between 0-100, got %d", r.StressLevel)
|
||||
}
|
||||
if r.HeartRate <= 0 {
|
||||
return fmt.Errorf("HeartRate must be positive, got %d", r.HeartRate)
|
||||
}
|
||||
if r.RRInterval <= 0 {
|
||||
return fmt.Errorf("RRInterval must be positive, got %d", r.RRInterval)
|
||||
}
|
||||
if r.SignalQuality < 0 || r.SignalQuality > 1 {
|
||||
return fmt.Errorf("SignalQuality must be between 0-1, got %f", r.SignalQuality)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate ensures HRVData meets all requirements
|
||||
func (h *HRVData) Validate() error {
|
||||
if h.UserProfilePK <= 0 {
|
||||
return errors.New("UserProfilePK must be positive")
|
||||
}
|
||||
if err := h.HRVSummary.Validate(); err != nil {
|
||||
return fmt.Errorf("HRVSummary validation failed: %w", err)
|
||||
}
|
||||
for i, reading := range h.HRVReadings {
|
||||
if err := reading.Validate(); err != nil {
|
||||
return fmt.Errorf("HRVReading[%d] validation failed: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DailyVariability calculates the average RR interval for the day
|
||||
func (h *HRVData) DailyVariability() float64 {
|
||||
if len(h.HRVReadings) == 0 {
|
||||
return 0
|
||||
}
|
||||
var total float64
|
||||
for _, r := range h.HRVReadings {
|
||||
total += r.RRSeconds()
|
||||
}
|
||||
return total / float64(len(h.HRVReadings))
|
||||
}
|
||||
|
||||
// MinHRVReading returns the reading with the lowest RR interval
|
||||
func (h *HRVData) MinHRVReading() HRVReading {
|
||||
if len(h.HRVReadings) == 0 {
|
||||
return HRVReading{}
|
||||
}
|
||||
min := h.HRVReadings[0]
|
||||
for _, r := range h.HRVReadings {
|
||||
if r.RRInterval < min.RRInterval {
|
||||
min = r
|
||||
}
|
||||
}
|
||||
return min
|
||||
}
|
||||
|
||||
// MaxHRVReading returns the reading with the highest RR interval
|
||||
func (h *HRVData) MaxHRVReading() HRVReading {
|
||||
if len(h.HRVReadings) == 0 {
|
||||
return HRVReading{}
|
||||
}
|
||||
max := h.HRVReadings[0]
|
||||
for _, r := range h.HRVReadings {
|
||||
if r.RRInterval > max.RRInterval {
|
||||
max = r
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// ParseHRVReadings converts values array to structured readings
|
||||
func ParseHRVReadings(valuesArray [][]any) []HRVReading {
|
||||
readings := make([]HRVReading, 0, len(valuesArray))
|
||||
// ParseHRVReadings converts body battery values array to structured readings
|
||||
func ParseHRVReadings(valuesArray [][]any) []types.HRVReading {
|
||||
readings := make([]types.HRVReading, 0, len(valuesArray))
|
||||
for _, values := range valuesArray {
|
||||
if len(values) < 6 {
|
||||
continue
|
||||
@@ -162,7 +55,7 @@ func ParseHRVReadings(valuesArray [][]any) []HRVReading {
|
||||
status, _ := values[4].(string)
|
||||
signalQuality, _ := values[5].(float64)
|
||||
|
||||
readings = append(readings, HRVReading{
|
||||
readings = append(readings, types.HRVReading{
|
||||
Timestamp: timestamp,
|
||||
StressLevel: stressLevel,
|
||||
HeartRate: heartRate,
|
||||
@@ -175,37 +68,4 @@ func ParseHRVReadings(valuesArray [][]any) []HRVReading {
|
||||
return readings[i].Timestamp < readings[j].Timestamp
|
||||
})
|
||||
return readings
|
||||
}
|
||||
|
||||
// Get implements the Data interface for HRVData
|
||||
func (h *HRVData) Get(day time.Time, client *client.Client) (any, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailyHrvData/%s?date=%s",
|
||||
client.Username, dateStr)
|
||||
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var hrvData HRVData
|
||||
if err := json.Unmarshal(data, &hrvData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := hrvData.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("HRV data validation failed: %w", err)
|
||||
}
|
||||
|
||||
return hrvData, nil
|
||||
}
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
func (h *HRVData) List(end time.Time, days int, client *client.Client, maxWorkers int) ([]any, error) {
|
||||
// Implementation to be added
|
||||
return []any{}, nil
|
||||
}
|
||||
}
|
||||
@@ -5,45 +5,27 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// SleepScores represents sleep scoring data
|
||||
type SleepScores struct {
|
||||
TotalSleepSeconds int `json:"totalSleepSeconds"`
|
||||
SleepScores []struct {
|
||||
StartTimeGMT time.Time `json:"startTimeGmt"`
|
||||
EndTimeGMT time.Time `json:"endTimeGmt"`
|
||||
SleepScore int `json:"sleepScore"`
|
||||
} `json:"sleepScores"`
|
||||
SleepMovement []SleepMovement `json:"sleepMovement"`
|
||||
// Add other fields from Python implementation
|
||||
}
|
||||
|
||||
// SleepMovement represents movement during sleep
|
||||
type SleepMovement struct {
|
||||
StartGMT time.Time `json:"startGmt"`
|
||||
EndGMT time.Time `json:"endGmt"`
|
||||
ActivityLevel int `json:"activityLevel"`
|
||||
}
|
||||
|
||||
// DailySleepDTO represents daily sleep data
|
||||
type DailySleepDTO struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
||||
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
||||
SleepScores SleepScores `json:"sleepScores"`
|
||||
BaseData
|
||||
SleepScores types.SleepScore `json:"sleepScores"` // Using types.SleepScore
|
||||
shared.BaseData
|
||||
}
|
||||
|
||||
// Get implements the Data interface for DailySleepDTO
|
||||
func (d *DailySleepDTO) Get(day time.Time, client *client.Client) (any, error) {
|
||||
func (d *DailySleepDTO) Get(day time.Time, c shared.APIClient) (any, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?nonSleepBufferMinutes=60&date=%s",
|
||||
client.Username, dateStr)
|
||||
c.GetUsername(), dateStr)
|
||||
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -54,7 +36,7 @@ func (d *DailySleepDTO) Get(day time.Time, client *client.Client) (any, error) {
|
||||
|
||||
var response struct {
|
||||
DailySleepDTO *DailySleepDTO `json:"dailySleepDto"`
|
||||
SleepMovement []SleepMovement `json:"sleepMovement"`
|
||||
SleepMovement []types.SleepMovement `json:"sleepMovement"` // Using types.SleepMovement
|
||||
}
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, err
|
||||
@@ -68,7 +50,7 @@ func (d *DailySleepDTO) Get(day time.Time, client *client.Client) (any, error) {
|
||||
}
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
func (d *DailySleepDTO) List(end time.Time, days int, client *client.Client, maxWorkers int) ([]any, error) {
|
||||
func (d *DailySleepDTO) List(end time.Time, days int, c shared.APIClient, maxWorkers int) ([]any, error) {
|
||||
// Implementation to be added
|
||||
return []any{}, nil
|
||||
}
|
||||
|
||||
68
internal/data/sleep_detailed.go
Normal file
68
internal/data/sleep_detailed.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
func (d *types.DetailedSleepData) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?date=%s&nonSleepBufferMinutes=60",
|
||||
c.GetUsername(), dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get detailed sleep data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
DailySleepDTO *types.DetailedSleepData `json:"dailySleepDTO"`
|
||||
SleepMovement []types.SleepMovement `json:"sleepMovement"`
|
||||
RemSleepData bool `json:"remSleepData"`
|
||||
SleepLevels []types.SleepLevel `json:"sleepLevels"`
|
||||
SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"`
|
||||
RestlessMomentsCount int `json:"restlessMomentsCount"`
|
||||
WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"`
|
||||
WellnessEpochSPO2DataDTOList []interface{} `json:"wellnessEpochSPO2DataDTOList"`
|
||||
WellnessEpochRespirationDataDTOList []interface{} `json:"wellnessEpochRespirationDataDTOList"`
|
||||
SleepStress interface{} `json:"sleepStress"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse detailed sleep response: %w", err)
|
||||
}
|
||||
|
||||
if response.DailySleepDTO == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Populate additional data
|
||||
response.DailySleepDTO.SleepMovement = response.SleepMovement
|
||||
response.DailySleepDTO.SleepLevels = response.SleepLevels
|
||||
|
||||
return response.DailySleepDTO, nil
|
||||
}
|
||||
|
||||
// GetSleepEfficiency calculates sleep efficiency percentage
|
||||
func (d *types.DetailedSleepData) GetSleepEfficiency() float64 {
|
||||
totalTime := d.SleepEndTimestampGMT.Sub(d.SleepStartTimestampGMT).Seconds()
|
||||
sleepTime := float64(d.DeepSleepSeconds + d.LightSleepSeconds + d.RemSleepSeconds)
|
||||
if totalTime == 0 {
|
||||
return 0
|
||||
}
|
||||
return (sleepTime / totalTime) * 100
|
||||
}
|
||||
|
||||
// GetTotalSleepTime returns total sleep time in hours
|
||||
func (d *types.DetailedSleepData) GetTotalSleepTime() float64 {
|
||||
totalSeconds := d.DeepSleepSeconds + d.LightSleepSeconds + d.RemSleepSeconds
|
||||
return float64(totalSeconds) / 3600.0
|
||||
}
|
||||
57
internal/data/training.go
Normal file
57
internal/data/training.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
func (t *types.TrainingStatus) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingStatus/%s", dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training status: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result types.TrainingStatus
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training status: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (t *types.TrainingLoad) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
endDate := day.AddDate(0, 0, 6).Format("2006-01-02") // Get week of data
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingLoad/%s/%s", dateStr, endDate)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training load: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []types.TrainingLoad
|
||||
if err := json.Unmarshal(data, &results); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training load: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &results[0], nil
|
||||
}
|
||||
93
internal/data/vo2max.go
Normal file
93
internal/data/vo2max.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// VO2MaxData implements the Data interface for VO2 max retrieval
|
||||
type VO2MaxData struct {
|
||||
shared.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 shared.APIClient) (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 shared.APIClient, 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 shared.APIClient) (*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
|
||||
}
|
||||
70
internal/data/vo2max_test.go
Normal file
70
internal/data/vo2max_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
"go-garth/internal/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestVO2MaxData_Get(t *testing.T) {
|
||||
// Setup
|
||||
runningVO2 := 45.0
|
||||
cyclingVO2 := 50.0
|
||||
settings := &client.UserSettings{
|
||||
ID: 12345,
|
||||
UserData: client.UserData{
|
||||
VO2MaxRunning: &runningVO2,
|
||||
VO2MaxCycling: &cyclingVO2,
|
||||
},
|
||||
}
|
||||
|
||||
vo2Data := NewVO2MaxData()
|
||||
|
||||
// Mock the get function
|
||||
vo2Data.GetFunc = func(day time.Time, c *client.Client) (interface{}, error) {
|
||||
vo2Profile := &models.VO2MaxProfile{
|
||||
UserProfilePK: settings.ID,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||
vo2Profile.Running = &models.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxRunning,
|
||||
ActivityType: "running",
|
||||
Date: day,
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||
vo2Profile.Cycling = &models.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxCycling,
|
||||
ActivityType: "cycling",
|
||||
Date: day,
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
return vo2Profile, nil
|
||||
}
|
||||
|
||||
// Test
|
||||
result, err := vo2Data.Get(time.Now(), nil) // client is not used in this mocked get
|
||||
|
||||
// Assertions
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
profile, ok := result.(*models.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)
|
||||
}
|
||||
@@ -5,25 +5,12 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// WeightData represents weight measurement data
|
||||
type WeightData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Weight float64 `json:"weight"` // in kilograms
|
||||
BMI float64 `json:"bmi"`
|
||||
BodyFatPercentage float64 `json:"bodyFatPercentage"`
|
||||
BoneMass float64 `json:"boneMass"` // in kg
|
||||
MuscleMass float64 `json:"muscleMass"` // in kg
|
||||
Hydration float64 `json:"hydration"` // in kg
|
||||
BaseData
|
||||
}
|
||||
|
||||
// Validate checks if weight data contains valid values
|
||||
func (w *WeightData) Validate() error {
|
||||
func (w *types.WeightData) Validate() error {
|
||||
if w.Weight <= 0 {
|
||||
return fmt.Errorf("invalid weight value")
|
||||
}
|
||||
@@ -34,13 +21,13 @@ func (w *WeightData) Validate() error {
|
||||
}
|
||||
|
||||
// Get implements the Data interface for WeightData
|
||||
func (w *WeightData) Get(day time.Time, client *client.Client) (any, error) {
|
||||
func (w *types.WeightData) Get(day time.Time, c shared.APIClient) (any, error) {
|
||||
startDate := day.Format("2006-01-02")
|
||||
endDate := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/weight-service/weight/dateRange?startDate=%s&endDate=%s",
|
||||
startDate, endDate)
|
||||
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -50,7 +37,7 @@ func (w *WeightData) Get(day time.Time, client *client.Client) (any, error) {
|
||||
}
|
||||
|
||||
var response struct {
|
||||
WeightList []WeightData `json:"weightList"`
|
||||
WeightList []types.WeightData `json:"weightList"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, err
|
||||
@@ -71,8 +58,8 @@ func (w *WeightData) Get(day time.Time, client *client.Client) (any, error) {
|
||||
}
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
func (w *WeightData) List(end time.Time, days int, client *client.Client, maxWorkers int) ([]any, error) {
|
||||
results, errs := w.BaseData.List(end, days, client, maxWorkers)
|
||||
func (w *types.WeightData) List(end time.Time, days int, c shared.APIClient, maxWorkers int) ([]any, error) {
|
||||
results, errs := w.BaseData.List(end, days, c, maxWorkers)
|
||||
if len(errs) > 0 {
|
||||
// Return first error for now
|
||||
return results, errs[0]
|
||||
|
||||
415
internal/models/types/garmin.go
Normal file
415
internal/models/types/garmin.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// Default location for conversions (set to UTC by default)
|
||||
defaultLocation *time.Location
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
defaultLocation, err = time.LoadLocation("UTC")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseTimestamp converts a millisecond timestamp to time.Time in default location
|
||||
func ParseTimestamp(ts int) time.Time {
|
||||
return time.Unix(0, int64(ts)*int64(time.Millisecond)).In(defaultLocation)
|
||||
}
|
||||
|
||||
// parseAggregationKey is a helper function to parse aggregation key back to a time.Time object
|
||||
func ParseAggregationKey(key, aggregate string) time.Time {
|
||||
switch aggregate {
|
||||
case "day":
|
||||
t, _ := time.Parse("2006-01-02", key)
|
||||
return t
|
||||
case "week":
|
||||
year, _ := strconv.Atoi(key[:4])
|
||||
week, _ := strconv.Atoi(key[6:])
|
||||
t := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
// Find the first Monday of the year
|
||||
for t.Weekday() != time.Monday {
|
||||
t = t.AddDate(0, 0, 1)
|
||||
}
|
||||
// Add weeks
|
||||
return t.AddDate(0, 0, (week-1)*7)
|
||||
case "month":
|
||||
t, _ := time.Parse("2006-01", key)
|
||||
return t
|
||||
case "year":
|
||||
t, _ := time.Parse("2006", key)
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// GarminTime represents Garmin's timestamp format with custom JSON parsing
|
||||
type GarminTime struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements the json.Unmarshaler interface.
|
||||
// It parses Garmin's specific timestamp format.
|
||||
func (gt *GarminTime) UnmarshalJSON(b []byte) (err error) {
|
||||
s := strings.Trim(string(b), `"`)
|
||||
if s == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try parsing with milliseconds (e.g., "2018-09-01T00:13:25.000")
|
||||
// Garmin sometimes returns .0 for milliseconds, which Go's time.Parse handles as .000
|
||||
// The 'Z' in the layout indicates a UTC time without a specific offset, which is often how these are interpreted.
|
||||
// If the input string does not contain 'Z', it will be parsed as local time.
|
||||
// For consistency, we'll assume UTC if no timezone is specified.
|
||||
layouts := []string{
|
||||
"2006-01-02T15:04:05.0", // Example: 2018-09-01T00:13:25.0
|
||||
"2006-01-02T15:04:05", // Example: 2018-09-01T00:13:25
|
||||
"2006-01-02", // Example: 2018-09-01
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
gt.Time = t
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot parse %q into a GarminTime", s)
|
||||
}
|
||||
|
||||
// SessionData represents saved session information
|
||||
type SessionData struct {
|
||||
Domain string `json:"domain"`
|
||||
Username string `json:"username"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
|
||||
// ActivityType represents the type of activity
|
||||
type ActivityType struct {
|
||||
TypeID int `json:"typeId"`
|
||||
TypeKey string `json:"typeKey"`
|
||||
ParentTypeID *int `json:"parentTypeId,omitempty"`
|
||||
}
|
||||
|
||||
// EventType represents the event type of an activity
|
||||
type EventType struct {
|
||||
TypeID int `json:"typeId"`
|
||||
TypeKey string `json:"typeKey"`
|
||||
}
|
||||
|
||||
// Activity represents a Garmin Connect activity
|
||||
type Activity struct {
|
||||
ActivityID int64 `json:"activityId"`
|
||||
ActivityName string `json:"activityName"`
|
||||
Description string `json:"description"`
|
||||
StartTimeLocal GarminTime `json:"startTimeLocal"`
|
||||
StartTimeGMT GarminTime `json:"startTimeGMT"`
|
||||
ActivityType ActivityType `json:"activityType"`
|
||||
EventType EventType `json:"eventType"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
ElapsedDuration float64 `json:"elapsedDuration"`
|
||||
MovingDuration float64 `json:"movingDuration"`
|
||||
ElevationGain float64 `json:"elevationGain"`
|
||||
ElevationLoss float64 `json:"elevationLoss"`
|
||||
AverageSpeed float64 `json:"averageSpeed"`
|
||||
MaxSpeed float64 `json:"maxSpeed"`
|
||||
Calories float64 `json:"calories"`
|
||||
AverageHR float64 `json:"averageHR"`
|
||||
MaxHR float64 `json:"maxHR"`
|
||||
}
|
||||
|
||||
// UserProfile represents a Garmin user profile
|
||||
type UserProfile struct {
|
||||
UserName string `json:"userName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LevelUpdateDate GarminTime `json:"levelUpdateDate"`
|
||||
// Add other fields as needed from API response
|
||||
}
|
||||
|
||||
// VO2MaxData represents VO2 max data
|
||||
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 VO2Max struct {
|
||||
Value float64 `json:"vo2Max"`
|
||||
FitnessLevel string `json:"fitnessLevel"`
|
||||
UpdatedDate time.Time `json:"date"`
|
||||
}
|
||||
|
||||
// SleepLevel represents different sleep stages
|
||||
type SleepLevel struct {
|
||||
StartGMT time.Time `json:"startGmt"`
|
||||
EndGMT time.Time `json:"endGmt"`
|
||||
ActivityLevel float64 `json:"activityLevel"`
|
||||
SleepLevel string `json:"sleepLevel"` // "deep", "light", "rem", "awake"
|
||||
}
|
||||
|
||||
// SleepMovement represents movement during sleep
|
||||
type SleepMovement struct {
|
||||
StartGMT time.Time `json:"startGmt"`
|
||||
EndGMT time.Time `json:"endGmt"`
|
||||
ActivityLevel float64 `json:"activityLevel"`
|
||||
}
|
||||
|
||||
// SleepScore represents detailed sleep scoring
|
||||
type SleepScore struct {
|
||||
Overall int `json:"overall"`
|
||||
Composition SleepScoreBreakdown `json:"composition"`
|
||||
Revitalization SleepScoreBreakdown `json:"revitalization"`
|
||||
Duration SleepScoreBreakdown `json:"duration"`
|
||||
DeepPercentage float64 `json:"deepPercentage"`
|
||||
LightPercentage float64 `json:"lightPercentage"`
|
||||
RemPercentage float64 `json:"remPercentage"`
|
||||
RestfulnessValue float64 `json:"restfulnessValue"`
|
||||
}
|
||||
|
||||
type SleepScoreBreakdown struct {
|
||||
QualifierKey string `json:"qualifierKey"`
|
||||
OptimalStart float64 `json:"optimalStart"`
|
||||
OptimalEnd float64 `json:"optimalEnd"`
|
||||
Value float64 `json:"value"`
|
||||
IdealStartSecs *int `json:"idealStartInSeconds"`
|
||||
IdealEndSecs *int `json:"idealEndInSeconds"`
|
||||
}
|
||||
|
||||
// DetailedSleepData represents comprehensive sleep data
|
||||
type DetailedSleepData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
||||
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
||||
SleepStartTimestampLocal time.Time `json:"sleepStartTimestampLocal"`
|
||||
SleepEndTimestampLocal time.Time `json:"sleepEndTimestampLocal"`
|
||||
UnmeasurableSleepSeconds int `json:"unmeasurableSleepSeconds"`
|
||||
DeepSleepSeconds int `json:"deepSleepSeconds"`
|
||||
LightSleepSeconds int `json:"lightSleepSeconds"`
|
||||
RemSleepSeconds int `json:"remSleepSeconds"`
|
||||
AwakeSleepSeconds int `json:"awakeSleepSeconds"`
|
||||
DeviceRemCapable bool `json:"deviceRemCapable"`
|
||||
SleepLevels []SleepLevel `json:"sleepLevels"`
|
||||
SleepMovement []SleepMovement `json:"sleepMovement"`
|
||||
SleepScores *SleepScore `json:"sleepScores"`
|
||||
AverageSpO2Value *float64 `json:"averageSpO2Value"`
|
||||
LowestSpO2Value *int `json:"lowestSpO2Value"`
|
||||
HighestSpO2Value *int `json:"highestSpO2Value"`
|
||||
AverageRespirationValue *float64 `json:"averageRespirationValue"`
|
||||
LowestRespirationValue *float64 `json:"lowestRespirationValue"`
|
||||
HighestRespirationValue *float64 `json:"highestRespirationValue"`
|
||||
AvgSleepStress *float64 `json:"avgSleepStress"`
|
||||
}
|
||||
|
||||
// HRVBaseline represents HRV baseline data
|
||||
type HRVBaseline struct {
|
||||
LowUpper int `json:"lowUpper"`
|
||||
BalancedLow int `json:"balancedLow"`
|
||||
BalancedUpper int `json:"balancedUpper"`
|
||||
MarkerValue float64 `json:"markerValue"`
|
||||
}
|
||||
|
||||
// DailyHRVData represents comprehensive daily HRV data
|
||||
type DailyHRVData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
WeeklyAvg *float64 `json:"weeklyAvg"`
|
||||
LastNightAvg *float64 `json:"lastNightAvg"`
|
||||
LastNight5MinHigh *float64 `json:"lastNight5MinHigh"`
|
||||
Baseline HRVBaseline `json:"baseline"`
|
||||
Status string `json:"status"`
|
||||
FeedbackPhrase string `json:"feedbackPhrase"`
|
||||
CreateTimeStamp time.Time `json:"createTimeStamp"`
|
||||
HRVReadings []HRVReading `json:"hrvReadings"`
|
||||
StartTimestampGMT time.Time `json:"startTimestampGmt"`
|
||||
EndTimestampGMT time.Time `json:"endTimestampGmt"`
|
||||
StartTimestampLocal time.Time `json:"startTimestampLocal"`
|
||||
EndTimestampLocal time.Time `json:"endTimestampLocal"`
|
||||
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
||||
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
||||
}
|
||||
|
||||
// BodyBatteryEvent represents events that impact Body Battery
|
||||
type BodyBatteryEvent struct {
|
||||
EventType string `json:"eventType"` // "sleep", "activity", "stress"
|
||||
EventStartTimeGMT time.Time `json:"eventStartTimeGmt"`
|
||||
TimezoneOffset int `json:"timezoneOffset"`
|
||||
DurationInMilliseconds int `json:"durationInMilliseconds"`
|
||||
BodyBatteryImpact int `json:"bodyBatteryImpact"`
|
||||
FeedbackType string `json:"feedbackType"`
|
||||
ShortFeedback string `json:"shortFeedback"`
|
||||
}
|
||||
|
||||
// DetailedBodyBatteryData represents comprehensive Body Battery data
|
||||
type DetailedBodyBatteryData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
StartTimestampGMT time.Time `json:"startTimestampGmt"`
|
||||
EndTimestampGMT time.Time `json:"endTimestampGmt"`
|
||||
StartTimestampLocal time.Time `json:"startTimestampLocal"`
|
||||
EndTimestampLocal time.Time `json:"endTimestampLocal"`
|
||||
MaxStressLevel int `json:"maxStressLevel"`
|
||||
AvgStressLevel int `json:"avgStressLevel"`
|
||||
BodyBatteryValuesArray [][]interface{} `json:"bodyBatteryValuesArray"`
|
||||
StressValuesArray [][]int `json:"stressValuesArray"`
|
||||
Events []BodyBatteryEvent `json:"bodyBatteryEvents"`
|
||||
}
|
||||
|
||||
// TrainingStatus represents current training status
|
||||
type TrainingStatus struct {
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
TrainingStatusKey string `json:"trainingStatusKey"` // "DETRAINING", "RECOVERY", "MAINTAINING", "PRODUCTIVE", "PEAKING", "OVERREACHING", "UNPRODUCTIVE", "NONE"
|
||||
TrainingStatusTypeKey string `json:"trainingStatusTypeKey"`
|
||||
TrainingStatusValue int `json:"trainingStatusValue"`
|
||||
LoadRatio float64 `json:"loadRatio"`
|
||||
}
|
||||
|
||||
// TrainingLoad represents training load data
|
||||
type TrainingLoad struct {
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
AcuteTrainingLoad float64 `json:"acuteTrainingLoad"`
|
||||
ChronicTrainingLoad float64 `json:"chronicTrainingLoad"`
|
||||
TrainingLoadRatio float64 `json:"trainingLoadRatio"`
|
||||
TrainingEffectAerobic float64 `json:"trainingEffectAerobic"`
|
||||
TrainingEffectAnaerobic float64 `json:"trainingEffectAnaerobic"`
|
||||
}
|
||||
|
||||
// FitnessAge represents fitness age calculation
|
||||
type FitnessAge struct {
|
||||
FitnessAge int `json:"fitnessAge"`
|
||||
ChronologicalAge int `json:"chronologicalAge"`
|
||||
VO2MaxRunning float64 `json:"vo2MaxRunning"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
}
|
||||
|
||||
// HeartRateZones represents heart rate zone data
|
||||
type HeartRateZones struct {
|
||||
RestingHR int `json:"resting_hr"`
|
||||
MaxHR int `json:"max_hr"`
|
||||
LactateThreshold int `json:"lactate_threshold"`
|
||||
Zones []HRZone `json:"zones"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// HRZone represents a single heart rate zone
|
||||
type HRZone struct {
|
||||
Zone int `json:"zone"`
|
||||
MinBPM int `json:"min_bpm"`
|
||||
MaxBPM int `json:"max_bpm"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// WellnessData represents additional wellness metrics
|
||||
type WellnessData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
RestingHR *int `json:"resting_hr"`
|
||||
Weight *float64 `json:"weight"`
|
||||
BodyFat *float64 `json:"body_fat"`
|
||||
BMI *float64 `json:"bmi"`
|
||||
BodyWater *float64 `json:"body_water"`
|
||||
BoneMass *float64 `json:"bone_mass"`
|
||||
MuscleMass *float64 `json:"muscle_mass"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// SleepData represents sleep summary data
|
||||
type SleepData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
SleepScore int `json:"sleepScore"`
|
||||
TotalSleepSeconds int `json:"totalSleepSeconds"`
|
||||
DeepSleepSeconds int `json:"deepSleepSeconds"`
|
||||
LightSleepSeconds int `json:"lightSleepSeconds"`
|
||||
RemSleepSeconds int `json:"remSleepSeconds"`
|
||||
AwakeSleepSeconds int `json:"awakeSleepSeconds"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// HrvData represents Heart Rate Variability data
|
||||
type HrvData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
HrvValue float64 `json:"hrvValue"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// HRVStatus represents HRV status and baseline
|
||||
type HRVStatus struct {
|
||||
Status string `json:"status"` // "BALANCED", "UNBALANCED", "POOR"
|
||||
FeedbackPhrase string `json:"feedbackPhrase"`
|
||||
BaselineLowUpper int `json:"baselineLowUpper"`
|
||||
BalancedLow int `json:"balancedLow"`
|
||||
BalancedUpper int `json:"balancedUpper"`
|
||||
MarkerValue float64 `json:"markerValue"`
|
||||
}
|
||||
|
||||
// HRVReading represents an individual HRV reading
|
||||
type HRVReading struct {
|
||||
Timestamp int `json:"timestamp"`
|
||||
StressLevel int `json:"stressLevel"`
|
||||
HeartRate int `json:"heartRate"`
|
||||
RRInterval int `json:"rrInterval"`
|
||||
Status string `json:"status"`
|
||||
SignalQuality float64 `json:"signalQuality"`
|
||||
}
|
||||
|
||||
// TimestampAsTime converts the reading timestamp to time.Time using timeutils
|
||||
func (r *HRVReading) TimestampAsTime() time.Time {
|
||||
return ParseTimestamp(r.Timestamp)
|
||||
}
|
||||
|
||||
// RRSeconds converts the RR interval to seconds
|
||||
func (r *HRVReading) RRSeconds() float64 {
|
||||
return float64(r.RRInterval) / 1000.0
|
||||
}
|
||||
|
||||
// StressData represents stress level data
|
||||
type StressData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
StressLevel int `json:"stressLevel"`
|
||||
RestStressLevel int `json:"restStressLevel"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// BodyBatteryData represents Body Battery data
|
||||
type BodyBatteryData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
BatteryLevel int `json:"batteryLevel"`
|
||||
Charge int `json:"charge"`
|
||||
Drain int `json:"drain"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// StepsData represents steps statistics
|
||||
type StepsData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Steps int `json:"steps"`
|
||||
}
|
||||
|
||||
// DistanceData represents distance statistics
|
||||
type DistanceData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Distance float64 `json:"distance"` // in meters
|
||||
}
|
||||
|
||||
// CaloriesData represents calories statistics
|
||||
type CaloriesData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Calories int `json:"activeCalories"`
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GarminTime represents Garmin's timestamp format with custom JSON parsing
|
||||
type GarminTime struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements the json.Unmarshaler interface.
|
||||
// It parses Garmin's specific timestamp format.
|
||||
func (gt *GarminTime) UnmarshalJSON(b []byte) (err error) {
|
||||
s := strings.Trim(string(b), `"`)
|
||||
if s == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try parsing with milliseconds (e.g., "2018-09-01T00:13:25.000")
|
||||
// Garmin sometimes returns .0 for milliseconds, which Go's time.Parse handles as .000
|
||||
// The 'Z' in the layout indicates a UTC time without a specific offset, which is often how these are interpreted.
|
||||
// If the input string does not contain 'Z', it will be parsed as local time.
|
||||
// For consistency, we'll assume UTC if no timezone is specified.
|
||||
layouts := []string{
|
||||
"2006-01-02T15:04:05.0", // Example: 2018-09-01T00:13:25.0
|
||||
"2006-01-02T15:04:05", // Example: 2018-09-01T00:13:25
|
||||
"2006-01-02", // Example: 2018-09-01
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
gt.Time = t
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot parse %q into a GarminTime", s)
|
||||
}
|
||||
|
||||
// SessionData represents saved session information
|
||||
type SessionData struct {
|
||||
Domain string `json:"domain"`
|
||||
Username string `json:"username"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
|
||||
// ActivityType represents the type of activity
|
||||
type ActivityType struct {
|
||||
TypeID int `json:"typeId"`
|
||||
TypeKey string `json:"typeKey"`
|
||||
ParentTypeID *int `json:"parentTypeId,omitempty"`
|
||||
}
|
||||
|
||||
// EventType represents the event type of an activity
|
||||
type EventType struct {
|
||||
TypeID int `json:"typeId"`
|
||||
TypeKey string `json:"typeKey"`
|
||||
}
|
||||
|
||||
// Activity represents a Garmin Connect activity
|
||||
type Activity struct {
|
||||
ActivityID int64 `json:"activityId"`
|
||||
ActivityName string `json:"activityName"`
|
||||
Description string `json:"description"`
|
||||
StartTimeLocal GarminTime `json:"startTimeLocal"`
|
||||
StartTimeGMT GarminTime `json:"startTimeGMT"`
|
||||
ActivityType ActivityType `json:"activityType"`
|
||||
EventType EventType `json:"eventType"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
ElapsedDuration float64 `json:"elapsedDuration"`
|
||||
MovingDuration float64 `json:"movingDuration"`
|
||||
ElevationGain float64 `json:"elevationGain"`
|
||||
ElevationLoss float64 `json:"elevationLoss"`
|
||||
AverageSpeed float64 `json:"averageSpeed"`
|
||||
MaxSpeed float64 `json:"maxSpeed"`
|
||||
Calories float64 `json:"calories"`
|
||||
AverageHR float64 `json:"averageHR"`
|
||||
MaxHR float64 `json:"maxHR"`
|
||||
}
|
||||
|
||||
// UserProfile represents a Garmin user profile
|
||||
type UserProfile struct {
|
||||
UserName string `json:"userName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LevelUpdateDate GarminTime `json:"levelUpdateDate"`
|
||||
// Add other fields as needed from API response
|
||||
}
|
||||
|
||||
// VO2MaxData represents VO2 max data
|
||||
type VO2MaxData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
VO2MaxRunning float64 `json:"vo2MaxRunning"`
|
||||
VO2MaxCycling float64 `json:"vo2MaxCycling"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// HeartRateZones represents heart rate zone data
|
||||
type HeartRateZones struct {
|
||||
RestingHR int `json:"resting_hr"`
|
||||
MaxHR int `json:"max_hr"`
|
||||
LactateThreshold int `json:"lactate_threshold"`
|
||||
Zones []HRZone `json:"zones"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// HRZone represents a single heart rate zone
|
||||
type HRZone struct {
|
||||
Zone int `json:"zone"`
|
||||
MinBPM int `json:"min_bpm"`
|
||||
MaxBPM int `json:"max_bpm"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// WellnessData represents additional wellness metrics
|
||||
type WellnessData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
RestingHR *int `json:"resting_hr"`
|
||||
Weight *float64 `json:"weight"`
|
||||
BodyFat *float64 `json:"body_fat"`
|
||||
BMI *float64 `json:"bmi"`
|
||||
BodyWater *float64 `json:"body_water"`
|
||||
BoneMass *float64 `json:"bone_mass"`
|
||||
MuscleMass *float64 `json:"muscle_mass"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// SleepData represents sleep summary data
|
||||
type SleepData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
SleepScore int `json:"sleepScore"`
|
||||
TotalSleepSeconds int `json:"totalSleepSeconds"`
|
||||
DeepSleepSeconds int `json:"deepSleepSeconds"`
|
||||
LightSleepSeconds int `json:"lightSleepSeconds"`
|
||||
RemSleepSeconds int `json:"remSleepSeconds"`
|
||||
AwakeSleepSeconds int `json:"awakeSleepSeconds"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// HrvData represents Heart Rate Variability data
|
||||
type HrvData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
HrvValue float64 `json:"hrvValue"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// StressData represents stress level data
|
||||
type StressData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
StressLevel int `json:"stressLevel"`
|
||||
RestStressLevel int `json:"restStressLevel"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// BodyBatteryData represents Body Battery data
|
||||
type BodyBatteryData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
BatteryLevel int `json:"batteryLevel"`
|
||||
Charge int `json:"charge"`
|
||||
Drain int `json:"drain"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// StepsData represents steps statistics
|
||||
type StepsData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Steps int `json:"steps"`
|
||||
}
|
||||
|
||||
// DistanceData represents distance statistics
|
||||
type DistanceData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Distance float64 `json:"distance"` // in meters
|
||||
}
|
||||
|
||||
// CaloriesData represents calories statistics
|
||||
type CaloriesData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Calories int `json:"activeCalories"`
|
||||
}
|
||||
@@ -2,64 +2,20 @@ package utils
|
||||
|
||||
import (
|
||||
"time"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var (
|
||||
// Default location for conversions (set to UTC by default)
|
||||
defaultLocation *time.Location
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
defaultLocation, err = time.LoadLocation("UTC")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// SetDefaultLocation sets the default time location for conversions
|
||||
func SetDefaultLocation(loc *time.Location) {
|
||||
defaultLocation = loc
|
||||
}
|
||||
|
||||
// ParseTimestamp converts a millisecond timestamp to time.Time in default location
|
||||
func ParseTimestamp(ts int) time.Time {
|
||||
return time.Unix(0, int64(ts)*int64(time.Millisecond)).In(defaultLocation)
|
||||
// defaultLocation = loc
|
||||
}
|
||||
|
||||
// ToLocalTime converts UTC time to local time using default location
|
||||
func ToLocalTime(utcTime time.Time) time.Time {
|
||||
return utcTime.In(defaultLocation)
|
||||
// return utcTime.In(defaultLocation)
|
||||
return utcTime // TODO: Implement proper time zone conversion
|
||||
}
|
||||
|
||||
// ToUTCTime converts local time to UTC
|
||||
func ToUTCTime(localTime time.Time) time.Time {
|
||||
return localTime.UTC()
|
||||
}
|
||||
|
||||
// parseAggregationKey is a helper function to parse aggregation key back to a time.Time object
|
||||
func ParseAggregationKey(key, aggregate string) time.Time {
|
||||
switch aggregate {
|
||||
case "day":
|
||||
t, _ := time.Parse("2006-01-02", key)
|
||||
return t
|
||||
case "week":
|
||||
year, _ := strconv.Atoi(key[:4])
|
||||
week, _ := strconv.Atoi(key[6:])
|
||||
t := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
// Find the first Monday of the year
|
||||
for t.Weekday() != time.Monday {
|
||||
t = t.AddDate(0, 0, 1)
|
||||
}
|
||||
// Add weeks
|
||||
return t.AddDate(0, 0, (week-1)*7)
|
||||
case "month":
|
||||
t, _ := time.Parse("2006-01", key)
|
||||
return t
|
||||
case "year":
|
||||
t, _ := time.Parse("2006", key)
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"go-garth/internal/types"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -16,10 +15,16 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var oauthConsumer *types.OAuthConsumer
|
||||
// OAuthConsumer represents OAuth consumer credentials
|
||||
type OAuthConsumer struct {
|
||||
ConsumerKey string `json:"consumer_key"`
|
||||
ConsumerSecret string `json:"consumer_secret"`
|
||||
}
|
||||
|
||||
var oauthConsumer *OAuthConsumer
|
||||
|
||||
// LoadOAuthConsumer loads OAuth consumer credentials
|
||||
func LoadOAuthConsumer() (*types.OAuthConsumer, error) {
|
||||
func LoadOAuthConsumer() (*OAuthConsumer, error) {
|
||||
if oauthConsumer != nil {
|
||||
return oauthConsumer, nil
|
||||
}
|
||||
@@ -29,7 +34,7 @@ func LoadOAuthConsumer() (*types.OAuthConsumer, error) {
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
var consumer types.OAuthConsumer
|
||||
var consumer OAuthConsumer
|
||||
if err := json.NewDecoder(resp.Body).Decode(&consumer); err == nil {
|
||||
oauthConsumer = &consumer
|
||||
return oauthConsumer, nil
|
||||
@@ -38,7 +43,7 @@ func LoadOAuthConsumer() (*types.OAuthConsumer, error) {
|
||||
}
|
||||
|
||||
// Fallback to hardcoded values
|
||||
oauthConsumer = &types.OAuthConsumer{
|
||||
oauthConsumer = &OAuthConsumer{
|
||||
ConsumerKey: "fc320c35-fbdc-4308-b5c6-8e41a8b2e0c8",
|
||||
ConsumerSecret: "8b344b8c-5bd5-4b7b-9c98-ad76a6bbf0e7",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user