Initial commit: smartrun MVP
Garmin run classification and progression tracker. Go backend (MCP client to mcp-garmin, SQLite store, deterministic rule engine, REST API) and React/TS frontend (Dashboard, Review Queue, Workout Kinds). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
204
backend/internal/sync/mapping.go
Normal file
204
backend/internal/sync/mapping.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"smartrun/backend/internal/classify"
|
||||
"smartrun/backend/internal/garmin"
|
||||
"smartrun/backend/internal/store"
|
||||
)
|
||||
|
||||
func toActivityRow(a garmin.Activity) store.Activity {
|
||||
return store.Activity{
|
||||
GarminActivityID: a.ActivityID,
|
||||
ActivityName: a.ActivityName,
|
||||
ActivityType: a.ActivityType.TypeKey,
|
||||
StartTimeUTC: a.StartTimeGMT,
|
||||
BeginTimestampMs: a.BeginTimestamp,
|
||||
DurationSeconds: a.Duration,
|
||||
DistanceMeters: a.Distance,
|
||||
AvgHR: nonZero(a.AverageHR),
|
||||
MaxHR: nonZero(a.MaxHR),
|
||||
AvgSpeedMps: nonZero(a.AverageSpeed),
|
||||
MaxSpeedMps: nonZero(a.MaxSpeed),
|
||||
ElevationGainM: a.ElevationGain,
|
||||
ElevationLossM: a.ElevationLoss,
|
||||
Calories: nonZero(a.Calories),
|
||||
LapCount: a.LapCount,
|
||||
AerobicTrainingEffect: nonZero(a.AerobicTrainingEffect),
|
||||
AnaerobicTrainingEffect: nonZero(a.AnaerobicTrainingEffect),
|
||||
TrainingEffectLabel: a.TrainingEffectLabel,
|
||||
VO2MaxValue: a.VO2MaxValue,
|
||||
HrTimeInZone1: nonZero(a.HrTimeInZone1),
|
||||
HrTimeInZone2: nonZero(a.HrTimeInZone2),
|
||||
HrTimeInZone3: nonZero(a.HrTimeInZone3),
|
||||
HrTimeInZone4: nonZero(a.HrTimeInZone4),
|
||||
HrTimeInZone5: nonZero(a.HrTimeInZone5),
|
||||
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 []garmin.Lap, samples []garmin.Sample) []store.Lap {
|
||||
rows := make([]store.Lap, 0, len(laps))
|
||||
var elapsedStart float64
|
||||
for _, 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
|
||||
}
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(l)
|
||||
rows = append(rows, store.Lap{
|
||||
LapIndex: l.LapIndex,
|
||||
StartTimeUTC: l.StartTimeGMT,
|
||||
DurationSeconds: l.Duration,
|
||||
DistanceMeters: l.Distance,
|
||||
AvgHR: nonZero(l.AverageHR),
|
||||
MaxHR: nonZero(l.MaxHR),
|
||||
AvgSpeedMps: nonZero(l.AverageSpeed),
|
||||
MaxSpeedMps: nonZero(l.MaxSpeed),
|
||||
ElevationGainM: nonZero(l.ElevationGain),
|
||||
ElevationLossM: nonZero(l.ElevationLoss),
|
||||
IntensityType: l.IntensityType,
|
||||
HRDriftBpmPerMin: driftPtr,
|
||||
HRRecoveryBpmPerMin: recoveryPtr,
|
||||
RawJSON: string(raw),
|
||||
})
|
||||
elapsedStart = elapsedEnd
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func samplesInWindow(samples []garmin.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 []garmin.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 {
|
||||
ctx := classify.MetricContext{
|
||||
"duration_seconds": a.DurationSeconds,
|
||||
"distance_meters": a.DistanceMeters,
|
||||
}
|
||||
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