refactor: merge internal/sync into internal/garmin, regroup api files and routes
Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes internal/log; the test mock moves into the garmin package as MockClient (breaking the test-only import cycle the merge created); stale test URLs and type names updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
302
backend/internal/garmin/mapping.go
Normal file
302
backend/internal/garmin/mapping.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/classify"
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
// isRunningActivityType reports whether a Garmin activityType.typeKey
|
||||
// represents a running activity (running, trail_running, treadmill_running,
|
||||
// track_running, indoor_running, virtual_run, ...) as opposed to other
|
||||
// sports (padel, cycling, strength training, ...) that also show up in
|
||||
// get_activities().
|
||||
func isRunningActivityType(typeKey string) bool {
|
||||
return strings.Contains(strings.ToLower(typeKey), "run")
|
||||
}
|
||||
|
||||
func toActivityRow(a Activity) store.Activity {
|
||||
return store.Activity{
|
||||
GarminActivityID: a.ActivityID,
|
||||
EventTypeKey: a.EventType.TypeKey,
|
||||
WorkoutID: a.WorkoutID,
|
||||
StartTimeUTC: a.StartTimeGMT,
|
||||
DurationSeconds: a.Duration,
|
||||
DistanceMeters: a.Distance,
|
||||
AvgHR: nonZero(a.AverageHR),
|
||||
MaxHR: nonZero(a.MaxHR),
|
||||
AvgSpeedMps: nonZero(a.AverageSpeed),
|
||||
ElevationGainM: a.ElevationGain,
|
||||
AerobicTrainingEffect: nonZero(a.AerobicTrainingEffect),
|
||||
AnaerobicTrainingEffect: nonZero(a.AnaerobicTrainingEffect),
|
||||
VO2MaxValue: a.VO2MaxValue,
|
||||
RawJSON: string(a.Raw),
|
||||
}
|
||||
}
|
||||
|
||||
// nonZero returns nil for a zero value so store columns stay NULL instead of
|
||||
// a misleading 0 when Garmin simply didn't report that field.
|
||||
func nonZero(v float64) *float64 {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// toLapRows converts garmin lap DTOs into store rows, computing each lap's
|
||||
// HR drift (active laps) or recovery rate (rest laps) from the samples that
|
||||
// fall within that lap's time window. Lap boundaries are derived from
|
||||
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps
|
||||
// are contiguous and this sidesteps timezone parsing entirely.
|
||||
func toLapRows(laps []Lap, samples []Sample, targets []*WorkoutStep, profile store.Profile) []store.Lap {
|
||||
rows := make([]store.Lap, 0, len(laps))
|
||||
var elapsedStart float64
|
||||
for i, l := range laps {
|
||||
elapsedEnd := elapsedStart + l.ElapsedDuration
|
||||
|
||||
var driftPtr, recoveryPtr *float64
|
||||
lapSamples := samplesInWindow(samples, elapsedStart, elapsedEnd)
|
||||
switch l.IntensityType {
|
||||
case "ACTIVE":
|
||||
if v, ok := classify.HRDrift(lapSamples); ok {
|
||||
driftPtr = &v
|
||||
}
|
||||
case "REST", "RECOVERY", "COOLDOWN", "WARMUP":
|
||||
if v, ok := classify.HRRecovery(lapSamples); ok {
|
||||
recoveryPtr = &v
|
||||
}
|
||||
}
|
||||
|
||||
var paceLow, paceHigh, hrLow, hrHigh *float64
|
||||
if i < len(targets) && targets[i] != nil {
|
||||
paceLow, paceHigh = targetPaceRange(*targets[i])
|
||||
hrLow, hrHigh = targetHRRange(*targets[i], profile)
|
||||
}
|
||||
|
||||
rows = append(rows, store.Lap{
|
||||
LapIndex: l.LapIndex,
|
||||
AvgSpeedMps: nonZero(l.AverageSpeed),
|
||||
IntensityType: l.IntensityType,
|
||||
HRDriftBpmPerMin: driftPtr,
|
||||
HRRecoveryBpmPerMin: recoveryPtr,
|
||||
TargetPaceLowMps: paceLow,
|
||||
TargetPaceHighMps: paceHigh,
|
||||
TargetHRLowBpm: hrLow,
|
||||
TargetHRHighBpm: hrHigh,
|
||||
RawJSON: string(l.Raw),
|
||||
})
|
||||
elapsedStart = elapsedEnd
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// alignWorkoutTargets zips an activity's lap count against its structured
|
||||
// workout's flattened steps, returning one *garmin.WorkoutStep per lap (nil
|
||||
// where unavailable).
|
||||
//
|
||||
// Confirmed against a real activity (via Garmin Connect's own workout view)
|
||||
// that recording sometimes continues one lap past the end of the workout's
|
||||
// last step -- e.g. a 5-minute prescribed cool-down followed by another
|
||||
// 6:46 the athlete just kept running, logged as a further lap Garmin never
|
||||
// defined a target for. That shows up here as exactly one more recorded lap
|
||||
// than the workout has steps, so that specific case zips the steps that do
|
||||
// exist and leaves the trailing extra lap unmapped, rather than discarding
|
||||
// every other lap's real target along with it.
|
||||
//
|
||||
// Any other mismatch (extra manual laps, auto-lap-by-distance also firing,
|
||||
// etc.) can't be trusted at all, so every entry comes back nil rather than
|
||||
// risk showing a target against the wrong lap.
|
||||
func alignWorkoutTargets(lapCount int, workout Workout) []*WorkoutStep {
|
||||
steps := workout.FlattenSteps()
|
||||
out := make([]*WorkoutStep, lapCount)
|
||||
|
||||
switch lapCount - len(steps) {
|
||||
case 0, 1:
|
||||
for i := range steps {
|
||||
s := steps[i]
|
||||
out[i] = &s
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// targetPaceRange returns the (low, high) m/s bounds of a workout step's
|
||||
// pace-zone target, or (nil, nil) if it doesn't target pace.
|
||||
func targetPaceRange(step WorkoutStep) (*float64, *float64) {
|
||||
if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
lo, hi := *step.TargetValueOne, *step.TargetValueTwo
|
||||
if lo > hi {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
return &lo, &hi
|
||||
}
|
||||
|
||||
// targetHRRange returns the (low, high) bpm bounds of a workout step's
|
||||
// heart-rate-zone target, or (nil, nil) if it doesn't target heart rate.
|
||||
// Steps that target a named zone (ZoneNumber) rather than a custom bpm
|
||||
// range are resolved via the user's Karvonen profile.
|
||||
func targetHRRange(step WorkoutStep, profile store.Profile) (*float64, *float64) {
|
||||
if step.TargetType.TypeKey != "heart.rate.zone" {
|
||||
return nil, nil
|
||||
}
|
||||
if step.TargetValueOne != nil && step.TargetValueTwo != nil {
|
||||
lo, hi := *step.TargetValueOne, *step.TargetValueTwo
|
||||
if lo > hi {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
return &lo, &hi
|
||||
}
|
||||
if step.ZoneNumber != nil {
|
||||
if lo, hi, ok := karvonenBounds(profile, *step.ZoneNumber); ok {
|
||||
return &lo, &hi
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// karvonenBounds resolves a named HR zone (1-5) to bpm bounds using the
|
||||
// user's max/resting heart rate and the zone's %HRR range, both from
|
||||
// Profile. ok is false when max/resting heart rate aren't configured, or
|
||||
// the zone number is out of range.
|
||||
func karvonenBounds(p store.Profile, zone int) (lowBpm, highBpm float64, ok bool) {
|
||||
if p.MaxHeartRate == nil || p.RestingHeartRate == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
maxHR, restHR := *p.MaxHeartRate, *p.RestingHeartRate
|
||||
|
||||
var minPct, maxPct float64
|
||||
switch zone {
|
||||
case 1:
|
||||
minPct, maxPct = p.HRZone1MinPct, p.HRZone1MaxPct
|
||||
case 2:
|
||||
minPct, maxPct = p.HRZone2MinPct, p.HRZone2MaxPct
|
||||
case 3:
|
||||
minPct, maxPct = p.HRZone3MinPct, p.HRZone3MaxPct
|
||||
case 4:
|
||||
minPct, maxPct = p.HRZone4MinPct, p.HRZone4MaxPct
|
||||
case 5:
|
||||
minPct, maxPct = p.HRZone5MinPct, p.HRZone5MaxPct
|
||||
default:
|
||||
return 0, 0, false
|
||||
}
|
||||
return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true
|
||||
}
|
||||
|
||||
func samplesInWindow(samples []Sample, start, end float64) []classify.SampleInfo {
|
||||
var out []classify.SampleInfo
|
||||
for _, s := range samples {
|
||||
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
|
||||
continue
|
||||
}
|
||||
out = append(out, classify.SampleInfo{ElapsedSeconds: s.ElapsedSeconds, HeartRate: s.HeartRate})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toSampleRows(samples []Sample) []store.Sample {
|
||||
rows := make([]store.Sample, len(samples))
|
||||
for i, s := range samples {
|
||||
rows[i] = store.Sample{
|
||||
ElapsedSeconds: s.ElapsedSeconds,
|
||||
TimestampMs: s.TimestampMS,
|
||||
HeartRate: s.HeartRate,
|
||||
SpeedMps: s.SpeedMps,
|
||||
DistanceM: s.DistanceM,
|
||||
ElevationM: s.ElevationM,
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// buildMetricContext computes the classify.MetricContext for one activity
|
||||
// from its stored summary and laps, ready to evaluate against workout kind
|
||||
// rules. maxHR is the user's configured max heart rate, used only to derive
|
||||
// avg_hr_pct_max (Garmin's activity/lap summaries don't include it directly).
|
||||
func buildMetricContext(a store.Activity, laps []store.Lap, maxHR float64) classify.MetricContext {
|
||||
isRace := 0.0
|
||||
if a.EventTypeKey == "race" {
|
||||
isRace = 1.0
|
||||
}
|
||||
ctx := classify.MetricContext{
|
||||
"duration_seconds": a.DurationSeconds,
|
||||
"distance_meters": a.DistanceMeters,
|
||||
"is_race": isRace,
|
||||
}
|
||||
if a.AvgSpeedMps != nil && *a.AvgSpeedMps > 0 {
|
||||
ctx["avg_pace_sec_per_km"] = 1000 / *a.AvgSpeedMps
|
||||
}
|
||||
if a.AvgHR != nil {
|
||||
ctx["avg_hr"] = *a.AvgHR
|
||||
if maxHR > 0 {
|
||||
ctx["avg_hr_pct_max"] = *a.AvgHR / maxHR
|
||||
}
|
||||
}
|
||||
if a.MaxHR != nil {
|
||||
ctx["max_hr"] = *a.MaxHR
|
||||
}
|
||||
if a.ElevationGainM != nil {
|
||||
ctx["elevation_gain_m"] = *a.ElevationGainM
|
||||
}
|
||||
if a.AerobicTrainingEffect != nil {
|
||||
ctx["aerobic_training_effect"] = *a.AerobicTrainingEffect
|
||||
}
|
||||
if a.AnaerobicTrainingEffect != nil {
|
||||
ctx["anaerobic_training_effect"] = *a.AnaerobicTrainingEffect
|
||||
}
|
||||
if a.VO2MaxValue != nil {
|
||||
ctx["vo2max_value"] = *a.VO2MaxValue
|
||||
}
|
||||
|
||||
lapInfos := make([]classify.LapInfo, len(laps))
|
||||
var paces []float64
|
||||
var maxDrift, maxRecovery float64
|
||||
haveDrift, haveRecovery := false, false
|
||||
for i, l := range laps {
|
||||
lapInfos[i] = classify.LapInfo{IntensityType: l.IntensityType}
|
||||
if l.AvgSpeedMps != nil && *l.AvgSpeedMps > 0 {
|
||||
paces = append(paces, 1000 / *l.AvgSpeedMps)
|
||||
}
|
||||
if l.HRDriftBpmPerMin != nil && (!haveDrift || *l.HRDriftBpmPerMin > maxDrift) {
|
||||
maxDrift, haveDrift = *l.HRDriftBpmPerMin, true
|
||||
}
|
||||
if l.HRRecoveryBpmPerMin != nil && (!haveRecovery || *l.HRRecoveryBpmPerMin > maxRecovery) {
|
||||
maxRecovery, haveRecovery = *l.HRRecoveryBpmPerMin, true
|
||||
}
|
||||
}
|
||||
if len(laps) > 0 {
|
||||
if classify.DetectIntervalPattern(lapInfos) {
|
||||
ctx["lap_interval_pattern"] = 1
|
||||
} else {
|
||||
ctx["lap_interval_pattern"] = 0
|
||||
}
|
||||
}
|
||||
if len(paces) > 0 {
|
||||
ctx["lap_pace_stddev"] = classify.LapPaceStdDev(paces)
|
||||
}
|
||||
if haveDrift {
|
||||
ctx["lap_hr_drift_bpm_per_min"] = maxDrift
|
||||
}
|
||||
if haveRecovery {
|
||||
ctx["lap_hr_recovery_bpm_per_min"] = maxRecovery
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func loadRuleKinds(kinds []store.WorkoutKind) ([]classify.RuleKind, error) {
|
||||
rules := make([]classify.RuleKind, 0, len(kinds))
|
||||
for _, k := range kinds {
|
||||
var node classify.Node
|
||||
if err := json.Unmarshal([]byte(k.RuleJSON), &node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, classify.RuleKind{WorkoutKindID: k.ID, Name: k.Name, Rule: node})
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func dateStr(t time.Time) string { return t.Format("2006-01-02") }
|
||||
Reference in New Issue
Block a user