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,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)
}

View 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)
}

View 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})
}

View 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)})
}

View 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)
}

View 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"})
}

View 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
}

View 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)
}