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:
67
backend/internal/api/activities.go
Normal file
67
backend/internal/api/activities.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"smartrun/backend/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
filter := store.ActivityFilter{
|
||||
FromDate: q.Get("from"),
|
||||
ToDate: q.Get("to"),
|
||||
}
|
||||
if limit, err := strconv.Atoi(q.Get("limit")); err == nil {
|
||||
filter.Limit = limit
|
||||
}
|
||||
if offset, err := strconv.Atoi(q.Get("offset")); err == nil {
|
||||
filter.Offset = offset
|
||||
}
|
||||
|
||||
activities, err := s.DB.ListActivities(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, activities)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
activity, ok, err := s.DB.GetActivity(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "activity not found")
|
||||
return
|
||||
}
|
||||
|
||||
laps, err := s.DB.LapsForActivity(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{"activity": activity, "laps": laps}
|
||||
if hasAssignment {
|
||||
resp["assignment"] = assignment
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
187
backend/internal/api/api_test.go
Normal file
187
backend/internal/api/api_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"smartrun/backend/internal/garmin/mock"
|
||||
"smartrun/backend/internal/store"
|
||||
appsync "smartrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func newCtx() context.Context { return context.Background() }
|
||||
|
||||
func newTestServer(t *testing.T) (*Server, *store.DB) {
|
||||
t.Helper()
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "smartrun_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
m := &mock.Client{}
|
||||
svc := appsync.NewService(m, db, appsync.Config{}, func() time.Time { return time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC) })
|
||||
return NewServer(db, m, svc), db
|
||||
}
|
||||
|
||||
func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request body: %v", err)
|
||||
}
|
||||
reader = bytes.NewReader(b)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkoutKindCRUD(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
router := s.Router()
|
||||
|
||||
createBody := map[string]any{
|
||||
"name": "Tempo",
|
||||
"rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`),
|
||||
}
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/workout-kinds/", createBody)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var created store.WorkoutKind
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("unmarshal created kind: %v", err)
|
||||
}
|
||||
if created.ID == 0 {
|
||||
t.Fatal("expected non-zero id")
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list status = %d", rec.Code)
|
||||
}
|
||||
var kinds []store.WorkoutKind
|
||||
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
||||
if len(kinds) != 1 {
|
||||
t.Fatalf("expected 1 kind, got %d", len(kinds))
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodDelete, "/api/workout-kinds/"+itoa(created.ID), nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete status = %d", rec.Code)
|
||||
}
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||||
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
||||
if len(kinds) != 0 {
|
||||
t.Fatalf("expected 0 active kinds after soft delete, got %d", len(kinds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkoutKindCreate_RejectsInvalidRule(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/workout-kinds/", map[string]any{
|
||||
"name": "Bad",
|
||||
"rule": json.RawMessage(`{"match":"xor","conditions":[]}`),
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewQueueResolve(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity: %v", err)
|
||||
}
|
||||
kindID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Easy", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||
}
|
||||
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
||||
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
||||
CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertKindAssignment: %v", err)
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("review queue status = %d", rec.Code)
|
||||
}
|
||||
var queue []map[string]any
|
||||
json.Unmarshal(rec.Body.Bytes(), &queue)
|
||||
if len(queue) != 1 {
|
||||
t.Fatalf("expected 1 item in review queue, got %d: %s", len(queue), rec.Body.String())
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("resolve status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||
json.Unmarshal(rec.Body.Bytes(), &queue)
|
||||
if len(queue) != 0 {
|
||||
t.Fatalf("expected empty review queue after resolve, got %d", len(queue))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
kindID, _ := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Easy", RuleJSON: `{}`, IsActive: true})
|
||||
|
||||
speed := 3.0
|
||||
a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
||||
a2, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, ActivityType: "running", StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
||||
|
||||
for _, id := range []int64{a2, a1} { // insert out of order on purpose
|
||||
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||
}
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/progression/"+itoa(kindID)+"?metric=pace", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var points []progressionPoint
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &points); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(points) != 2 {
|
||||
t.Fatalf("expected 2 points, got %d", len(points))
|
||||
}
|
||||
if points[0].Date > points[1].Date {
|
||||
t.Errorf("points not sorted ascending by date: %+v", points)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
72
backend/internal/api/auth.go
Normal file
72
backend/internal/api/auth.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"smartrun/backend/internal/garmin"
|
||||
)
|
||||
|
||||
type authResponse struct {
|
||||
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func authStatusString(s garmin.AuthStatus) string {
|
||||
switch s {
|
||||
case garmin.AuthSuccess:
|
||||
return "authenticated"
|
||||
case garmin.AuthMFARequired:
|
||||
return "mfa_required"
|
||||
case garmin.AuthFailed:
|
||||
return "failed"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) recordAuthResult(res garmin.AuthResult) {
|
||||
s.mu.Lock()
|
||||
s.authStatus = res.Status
|
||||
s.authMessage = res.Message
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := s.Garmin.Authenticate(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAuthResult(res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Code == "" {
|
||||
writeError(w, http.StatusBadRequest, "code is required")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := s.Garmin.CompleteMFA(r.Context(), body.Code)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAuthResult(res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
status, msg := s.authStatus, s.authMessage
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
|
||||
}
|
||||
192
backend/internal/api/kinds.go
Normal file
192
backend/internal/api/kinds.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"smartrun/backend/internal/classify"
|
||||
"smartrun/backend/internal/store"
|
||||
)
|
||||
|
||||
type workoutKindRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
Rule json.RawMessage `json:"rule"`
|
||||
Priority int `json:"priority"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func (req workoutKindRequest) validate() (classify.Node, error) {
|
||||
var node classify.Node
|
||||
if req.Name == "" {
|
||||
return node, errors.New("name is required")
|
||||
}
|
||||
if err := json.Unmarshal(req.Rule, &node); err != nil {
|
||||
return node, errors.New("rule is not valid JSON: " + err.Error())
|
||||
}
|
||||
if err := node.Validate(); err != nil {
|
||||
return node, errors.New("invalid rule: " + err.Error())
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) {
|
||||
activeOnly := r.URL.Query().Get("include_inactive") != "true"
|
||||
kinds, err := s.DB.ListWorkoutKinds(r.Context(), activeOnly)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, kinds)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||
return
|
||||
}
|
||||
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "workout kind not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, kind)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
||||
var req workoutKindRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if _, err := req.validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
isActive := true
|
||||
if req.IsActive != nil {
|
||||
isActive = *req.IsActive
|
||||
}
|
||||
|
||||
id, err := s.DB.CreateWorkoutKind(r.Context(), store.WorkoutKind{
|
||||
Name: req.Name, Description: req.Description, Color: req.Color,
|
||||
RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id)
|
||||
writeJSON(w, http.StatusCreated, kind)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||
return
|
||||
}
|
||||
existing, ok, err := s.DB.GetWorkoutKind(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "workout kind not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req workoutKindRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if _, err := req.validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
isActive := existing.IsActive
|
||||
if req.IsActive != nil {
|
||||
isActive = *req.IsActive
|
||||
}
|
||||
|
||||
if err := s.DB.UpdateWorkoutKind(r.Context(), store.WorkoutKind{
|
||||
ID: id, Name: req.Name, Description: req.Description, Color: req.Color,
|
||||
RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id)
|
||||
writeJSON(w, http.StatusOK, kind)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||
return
|
||||
}
|
||||
if err := s.DB.SoftDeleteWorkoutKind(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleReclassifyKind re-runs the rule engine for every activity currently
|
||||
// assigned to (or under review for) this kind. It's a synchronous, bounded
|
||||
// operation (unlike sync), so it runs inline rather than in the background.
|
||||
func (s *Server) handleReclassifyKind(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||
return
|
||||
}
|
||||
|
||||
assignments, err := s.DB.AssignmentsForKind(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
reviewQueue, err := s.DB.ReviewQueue(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
seen := make(map[int64]bool)
|
||||
var activityIDs []int64
|
||||
for _, a := range assignments {
|
||||
if !seen[a.ActivityID] {
|
||||
seen[a.ActivityID] = true
|
||||
activityIDs = append(activityIDs, a.ActivityID)
|
||||
}
|
||||
}
|
||||
for _, a := range reviewQueue {
|
||||
if !seen[a.ActivityID] {
|
||||
seen[a.ActivityID] = true
|
||||
activityIDs = append(activityIDs, a.ActivityID)
|
||||
}
|
||||
}
|
||||
|
||||
for _, activityID := range activityIDs {
|
||||
if err := s.Sync.ClassifyActivity(r.Context(), activityID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"reclassified": len(activityIDs)})
|
||||
}
|
||||
96
backend/internal/api/progression.go
Normal file
96
backend/internal/api/progression.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"smartrun/backend/internal/store"
|
||||
)
|
||||
|
||||
type progressionPoint struct {
|
||||
Date string `json:"date"`
|
||||
ActivityID int64 `json:"activity_id"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
func metricValue(metric string, a store.Activity) (float64, bool) {
|
||||
switch metric {
|
||||
case "pace":
|
||||
if a.AvgSpeedMps == nil || *a.AvgSpeedMps <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return 1000 / *a.AvgSpeedMps, true
|
||||
case "hr":
|
||||
if a.AvgHR == nil {
|
||||
return 0, false
|
||||
}
|
||||
return *a.AvgHR, true
|
||||
case "vo2max":
|
||||
if a.VO2MaxValue == nil {
|
||||
return 0, false
|
||||
}
|
||||
return *a.VO2MaxValue, true
|
||||
case "aerobic_te":
|
||||
if a.AerobicTrainingEffect == nil {
|
||||
return 0, false
|
||||
}
|
||||
return *a.AerobicTrainingEffect, true
|
||||
case "anaerobic_te":
|
||||
if a.AnaerobicTrainingEffect == nil {
|
||||
return 0, false
|
||||
}
|
||||
return *a.AnaerobicTrainingEffect, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// handleProgression returns a time series of the requested metric for every
|
||||
// activity currently assigned to a workout kind, for progression charts.
|
||||
func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) {
|
||||
kindID, err := strconv.ParseInt(chi.URLParam(r, "kindID"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||
return
|
||||
}
|
||||
metric := r.URL.Query().Get("metric")
|
||||
if metric == "" {
|
||||
metric = "pace"
|
||||
}
|
||||
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
|
||||
|
||||
assignments, err := s.DB.AssignmentsForKind(r.Context(), kindID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
points := []progressionPoint{}
|
||||
for _, a := range assignments {
|
||||
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if from != "" && activity.StartTimeUTC < from {
|
||||
continue
|
||||
}
|
||||
if to != "" && activity.StartTimeUTC > to+" 23:59:59" {
|
||||
continue
|
||||
}
|
||||
value, ok := metricValue(metric, activity)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
points = append(points, progressionPoint{Date: activity.StartTimeUTC, ActivityID: activity.ID, Value: value})
|
||||
}
|
||||
|
||||
sort.Slice(points, func(i, j int) bool { return points[i].Date < points[j].Date })
|
||||
writeJSON(w, http.StatusOK, points)
|
||||
}
|
||||
78
backend/internal/api/review.go
Normal file
78
backend/internal/api/review.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"smartrun/backend/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
queue, err := s.DB.ReviewQueue(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
type item struct {
|
||||
store.KindAssignment
|
||||
Activity store.Activity `json:"activity"`
|
||||
}
|
||||
items := make([]item, 0, len(queue))
|
||||
for _, a := range queue {
|
||||
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{KindAssignment: a, Activity: activity})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
WorkoutKindID int64 `json:"workout_kind_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.WorkoutKindID == 0 {
|
||||
writeError(w, http.StatusBadRequest, "workout_kind_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok, err := s.DB.GetWorkoutKind(r.Context(), body.WorkoutKindID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
} else if !ok {
|
||||
writeError(w, http.StatusBadRequest, "workout kind not found")
|
||||
return
|
||||
}
|
||||
|
||||
kindID := body.WorkoutKindID
|
||||
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
|
||||
ActivityID: activityID,
|
||||
WorkoutKindID: &kindID,
|
||||
AssignmentSource: store.AssignmentSourceManual,
|
||||
Status: store.AssignmentStatusAssigned,
|
||||
CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
|
||||
}
|
||||
137
backend/internal/api/server.go
Normal file
137
backend/internal/api/server.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// Package api is smartrun's HTTP layer: REST handlers over internal/store,
|
||||
// internal/garmin, and internal/sync.
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"smartrun/backend/internal/garmin"
|
||||
"smartrun/backend/internal/store"
|
||||
appsync "smartrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
// Server wires the HTTP handlers to the app's dependencies.
|
||||
type Server struct {
|
||||
DB *store.DB
|
||||
Garmin garmin.Client
|
||||
Sync *appsync.Service
|
||||
|
||||
mu sync.Mutex
|
||||
authStatus garmin.AuthStatus
|
||||
authMessage string
|
||||
syncRunning bool
|
||||
}
|
||||
|
||||
// NewServer builds a Server.
|
||||
func NewServer(db *store.DB, g garmin.Client, s *appsync.Service) *Server {
|
||||
return &Server{DB: db, Garmin: g, Sync: s}
|
||||
}
|
||||
|
||||
// Router builds the HTTP routes.
|
||||
func (s *Server) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(corsMiddleware)
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/health", s.handleHealth)
|
||||
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", s.handleAuthLogin)
|
||||
r.Post("/mfa", s.handleAuthMFA)
|
||||
r.Get("/status", s.handleAuthStatus)
|
||||
})
|
||||
|
||||
r.Route("/sync", func(r chi.Router) {
|
||||
r.Post("/run", s.handleSyncRun)
|
||||
r.Post("/backfill", s.handleSyncBackfill)
|
||||
r.Get("/runs", s.handleSyncRuns)
|
||||
r.Get("/status", s.handleSyncStatus)
|
||||
})
|
||||
|
||||
r.Route("/activities", func(r chi.Router) {
|
||||
r.Get("/", s.handleListActivities)
|
||||
r.Get("/{id}", s.handleGetActivity)
|
||||
})
|
||||
|
||||
r.Route("/workout-kinds", func(r chi.Router) {
|
||||
r.Get("/", s.handleListWorkoutKinds)
|
||||
r.Post("/", s.handleCreateWorkoutKind)
|
||||
r.Get("/{id}", s.handleGetWorkoutKind)
|
||||
r.Put("/{id}", s.handleUpdateWorkoutKind)
|
||||
r.Delete("/{id}", s.handleDeleteWorkoutKind)
|
||||
r.Post("/{id}/reclassify", s.handleReclassifyKind)
|
||||
})
|
||||
|
||||
r.Route("/review-queue", func(r chi.Router) {
|
||||
r.Get("/", s.handleReviewQueue)
|
||||
r.Post("/{activityID}/resolve", s.handleResolveReview)
|
||||
})
|
||||
|
||||
r.Get("/progression/{kindID}", s.handleProgression)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// corsMiddleware allows the frontend dev server (a different port) to call
|
||||
// this API. Single-user local app, so reflecting any origin is fine --
|
||||
// there's no session/cookie auth to protect against CSRF.
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("api: encode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
|
||||
// only one sync operation runs at a time. Returns false if one is already
|
||||
// in progress.
|
||||
func (s *Server) backgroundSync(fn func(ctx context.Context) error) bool {
|
||||
s.mu.Lock()
|
||||
if s.syncRunning {
|
||||
s.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
s.syncRunning = true
|
||||
s.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.syncRunning = false
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
if err := fn(context.Background()); err != nil {
|
||||
log.Printf("api: background sync error: %v", err)
|
||||
}
|
||||
}()
|
||||
return true
|
||||
}
|
||||
77
backend/internal/api/sync.go
Normal file
77
backend/internal/api/sync.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// detailFillBatchSize bounds how many activities' details/splits are fetched
|
||||
// per sync trigger, matching the sequential rate-limited fetch in
|
||||
// internal/sync.Service.FillPendingDetails.
|
||||
const detailFillBatchSize = 50
|
||||
|
||||
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||
ok := s.backgroundSync(func(ctx context.Context) error {
|
||||
if err := s.Sync.IncrementalSync(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Sync.FillPendingDetails(ctx, detailFillBatchSize)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncBackfill(w http.ResponseWriter, r *http.Request) {
|
||||
ok := s.backgroundSync(func(ctx context.Context) error {
|
||||
if err := s.Sync.Backfill(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Sync.FillPendingDetails(ctx, detailFillBatchSize)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
|
||||
runs, err := s.DB.ListSyncRuns(r.Context(), 20)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, runs)
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
|
||||
run, ok, err := s.DB.LatestSyncRun(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
inProgress := s.syncRunning
|
||||
s.mu.Unlock()
|
||||
|
||||
progress := s.Sync.Progress()
|
||||
|
||||
resp := map[string]any{
|
||||
"in_progress": inProgress,
|
||||
"detail_fill_progress": progress,
|
||||
"activities_pending_details": remaining,
|
||||
}
|
||||
if ok {
|
||||
resp["last_run"] = run
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
Reference in New Issue
Block a user