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:
2026-07-17 18:33:06 +02:00
commit f689f74ae0
69 changed files with 10666 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
// Package config loads smartrund's runtime configuration from environment
// variables (a single-user local app has no need for a config file/flags
// beyond this).
package config
import (
"fmt"
"os"
"strconv"
"time"
)
// Config holds everything smartrund needs to start.
type Config struct {
// Addr is the HTTP listen address, e.g. ":8080".
Addr string
// DBPath is the SQLite database file path.
DBPath string
// GarminPythonPath is mcp-garmin's venv python executable.
GarminPythonPath string
// GarminServerPath is mcp-garmin's server.py.
GarminServerPath string
GarminEmail string
GarminPassword string
// GarminTokenStore, if set, overrides mcp-garmin's default ~/.garth
// session cache location.
GarminTokenStore string
MaxHR float64
MinConfidence float64
BackfillHorizonDays int
IncrementalSyncEvery time.Duration
}
// Load reads configuration from environment variables, applying defaults
// for anything optional. Returns an error if a required variable is unset.
func Load() (Config, error) {
cfg := Config{
Addr: getEnvDefault("SMARTRUN_ADDR", ":8080"),
DBPath: getEnvDefault("SMARTRUN_DB_PATH", "smartrun.db"),
GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"),
GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"),
GarminEmail: os.Getenv("GARMIN_EMAIL"),
GarminPassword: os.Getenv("GARMIN_PASSWORD"),
GarminTokenStore: os.Getenv("GARMIN_TOKENSTORE"),
MaxHR: getEnvFloat("SMARTRUN_MAX_HR", 190),
MinConfidence: getEnvFloat("SMARTRUN_MIN_CONFIDENCE", 0.6),
BackfillHorizonDays: getEnvInt("SMARTRUN_BACKFILL_HORIZON_DAYS", 3*365),
IncrementalSyncEvery: getEnvDuration("SMARTRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
}
if cfg.GarminPythonPath == "" {
return cfg, fmt.Errorf("MCP_GARMIN_PYTHON is required (path to mcp-garmin's venv python)")
}
if cfg.GarminServerPath == "" {
return cfg, fmt.Errorf("MCP_GARMIN_SERVER is required (path to mcp-garmin's server.py)")
}
if cfg.GarminEmail == "" || cfg.GarminPassword == "" {
return cfg, fmt.Errorf("GARMIN_EMAIL and GARMIN_PASSWORD are required")
}
return cfg, nil
}
func getEnvDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getEnvFloat(key string, def float64) float64 {
if v := os.Getenv(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return def
}
func getEnvInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func getEnvDuration(key string, def time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return def
}