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:
2026-08-04 16:04:18 +02:00
parent 19c9aecdeb
commit e2b2bf9611
61 changed files with 2007 additions and 982 deletions

View File

@@ -14,22 +14,21 @@ import (
"time"
"geniusrun/backend/internal/api"
"geniusrun/backend/internal/applog"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/config"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/log"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
func main() {
cfg, err := config.Load()
envCfg, err := config.LoadEnv()
if err != nil {
log.Fatalf("config: %v", err)
}
slog.SetDefault(applog.NewLogger(cfg.LogLevel, os.Stdout))
slog.SetDefault(applog.NewLogger(envCfg.LogLevel, os.Stdout))
db, err := store.Open(cfg.DBPath)
db, err := store.Open(envCfg.DBPath)
if err != nil {
log.Fatalf("open database: %v", err)
}
@@ -45,37 +44,43 @@ func main() {
}
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
IssuerURL: cfg.OIDCIssuerURL,
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret,
RedirectURL: cfg.OIDCRedirectURL,
RequiredRole: cfg.OIDCRequiredRole,
IssuerURL: envCfg.OIDCIssuerURL,
ClientID: envCfg.OIDCClientID,
ClientSecret: envCfg.OIDCClientSecret,
RedirectURL: envCfg.OIDCRedirectURL,
RequiredRole: envCfg.OIDCRequiredRole,
})
if err != nil {
log.Fatalf("oidc: %v", err)
}
server := api.NewServer(db, garmin.NewClient, garmin.Config{
PythonPath: cfg.GarminPythonPath,
TokenStorePath: cfg.GarminTokenStoreRoot,
}, appsync.Config{}, authVerifier, api.SessionConfig{
Secret: cfg.SessionSecret,
Duration: appCfg.SessionDuration,
Secure: cfg.SessionSecure,
BackendURL: cfg.BackendURL,
FrontendURL: cfg.FrontendURL,
})
server := api.NewServer(
db,
garmin.NewClient,
garmin.ClientConfig{
PythonPath: envCfg.PythonPath,
TokenStorePath: envCfg.TokenStoreRoot,
},
garmin.SyncConfig{},
authVerifier,
api.SessionConfig{
Secret: envCfg.SessionSecret,
Duration: appCfg.SessionDuration,
Secure: envCfg.SessionSecure,
BackendURL: envCfg.BackendURL,
FrontendURL: envCfg.FrontendURL,
})
for _, e := range cfg.DisplayEnv() {
for _, e := range envCfg.DisplayEnv() {
server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value})
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
httpServer := &http.Server{Addr: envCfg.BackendAddr, Handler: server.Router()}
go func() {
log.Printf("geniusrund listening on %s", cfg.Addr)
log.Printf("geniusrund listening on %s", envCfg.BackendAddr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err)
}

View File

@@ -13,9 +13,8 @@ import (
"time"
"geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
func main() {
@@ -73,8 +72,8 @@ func main() {
IsActive: true,
}))
m := &mock.Client{}
svc := appsync.NewService(m, db, userID, appsync.Config{MinConfidence: 0.6}, nil)
m := &garmin.MockClient{}
svc := garmin.NewSync(m, db, userID, garmin.SyncConfig{MinConfidence: 0.6}, nil)
today := time.Now()
activityIDs := []int64{}

BIN
backend/geniusrun.db-shm Normal file

Binary file not shown.

BIN
backend/geniusrun.db-wal Normal file

Binary file not shown.

View File

@@ -15,13 +15,11 @@ import (
"testing"
"time"
"geniusrun/backend/internal/applog"
"geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/log"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
func newCtx() context.Context { return context.Background() }
@@ -47,9 +45,9 @@ func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
t.Fatalf("ProvisionUser: %v", err)
}
m := &mock.Client{}
garminFactory := func(garmin.Config) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
m := &garmin.MockClient{}
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
return s, db, userID
}
@@ -586,7 +584,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/sync/reset", nil)
rec := doJSON(t, router, http.MethodPost, "/api/garmin/sync/reset", nil)
if rec.Code != http.StatusAccepted {
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String())
}
@@ -607,9 +605,9 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
}
// "Full backfill" no longer exists as an endpoint -- superseded by reset.
rec = doJSON(t, router, http.MethodPost, "/api/sync/backfill", nil)
rec = doJSON(t, router, http.MethodPost, "/api/garmin/sync/backfill", nil)
if rec.Code != http.StatusNotFound {
t.Errorf("/api/sync/backfill status = %d, want 404 (removed)", rec.Code)
t.Errorf("/api/garmin/sync/backfill status = %d, want 404 (removed)", rec.Code)
}
}
@@ -633,7 +631,7 @@ func TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending(t *testing.T) {
t.Fatalf("SetActivityDetails: %v", err)
}
rec := doJSON(t, s.Router(), http.MethodGet, "/api/sync/status", nil)
rec := doJSON(t, s.Router(), http.MethodGet, "/api/garmin/sync/status", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
@@ -777,7 +775,7 @@ func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
t.Fatal("expected a fresh account to report garmin_connected=false")
}
rec = doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // mock.Client defaults to AuthSuccess
rec = doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // garmin.MockClient defaults to AuthSuccess
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
@@ -792,18 +790,18 @@ func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) {
s, _, userID := newTestServer(t)
client, err := s.garminFor(context.Background(), userID)
client, err := s.clientFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*mock.Client)
mockClient, ok := client.(*garmin.MockClient)
if !ok {
t.Fatalf("expected *mock.Client, got %T", client)
t.Fatalf("expected *garmin.MockClient, got %T", client)
}
mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
@@ -821,17 +819,17 @@ func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
router := s.Router()
// Force the per-user Garmin client to be built and cached.
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
client, err := s.garminFor(context.Background(), userID)
client, err := s.clientFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*mock.Client)
mockClient, ok := client.(*garmin.MockClient)
if !ok {
t.Fatalf("expected *mock.Client, got %T", client)
t.Fatalf("expected *garmin.MockClient, got %T", client)
}
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
@@ -882,9 +880,9 @@ func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) {
t.Fatalf("WriteFile: %v", err)
}
m := &mock.Client{}
garminFactory := func(garmin.Config) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
m := &garmin.MockClient{}
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {

View File

@@ -1,98 +0,0 @@
package api
import (
"context"
"encoding/json"
"log"
"net/http"
"geniusrun/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"
}
}
// recordAuthResult updates the in-memory auth status/message for userID,
// and -- on a successful authentication -- persists that this account has
// connected to Garmin at least once (store.MarkGarminConnected), which is
// what the login gate actually checks (the in-memory auth status resets on
// every backend restart; this doesn't).
func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.AuthResult) {
s.mu.Lock()
s.userAuthStatus[userID] = res.Status
s.userAuthMessage[userID] = res.Message
s.mu.Unlock()
if res.Status == garmin.AuthSuccess {
if err := s.DB.MarkGarminConnected(ctx, userID); err != nil {
log.Printf("api: mark garmin connected for user %d: %v", userID, err)
}
}
}
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
client, err := s.garminFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
res, err := client.Authenticate(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordAuthResult(r.Context(), userID, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
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
}
client, err := s.garminFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
res, err := client.CompleteMFA(r.Context(), body.Code)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordAuthResult(r.Context(), userID, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
s.mu.Lock()
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
s.mu.Unlock()
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
}

View File

@@ -14,7 +14,7 @@ type EnvVar struct {
Value string `json:"value"`
}
type appConfigEntry struct {
type configEntry struct {
Key string `json:"key"`
Value string `json:"value"`
Default string `json:"default"`
@@ -30,13 +30,13 @@ func (s *Server) configView(r *http.Request) (map[string]any, error) {
return nil, err
}
registry := config.AppRegistry()
app := make([]appConfigEntry, 0, len(registry))
app := make([]configEntry, 0, len(registry))
for _, k := range registry {
value, overridden := overrides[k.Key]
if !overridden {
value = k.Default
}
app = append(app, appConfigEntry{
app = append(app, configEntry{
Key: k.Key, Value: value, Default: k.Default,
Overridden: overridden, Description: k.Description,
})

View File

@@ -5,12 +5,9 @@ import (
"net/http"
"testing"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
)
type configTestResponse struct {
@@ -87,8 +84,8 @@ func TestConfig_RequiresProvisionedUser(t *testing.T) {
t.Fatalf("store.Open: %v", err)
}
defer db.Close()
m := &mock.Client{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
m := &garmin.MockClient{}
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
if rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil); rec.Code != http.StatusForbidden {
t.Fatalf("GET status = %d, want 403", rec.Code)

View File

@@ -0,0 +1,200 @@
package api
import (
"context"
"encoding/json"
"log"
"net/http"
"geniusrun/backend/internal/garmin"
)
// 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
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"
}
}
// recordAuthResult updates the in-memory auth status/message for userID,
// and -- on a successful authentication -- persists that this account has
// connected to Garmin at least once (store.MarkGarminConnected), which is
// what the login gate actually checks (the in-memory auth status resets on
// every backend restart; this doesn't).
func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.AuthResult) {
s.mu.Lock()
s.userAuthStatus[userID] = res.Status
s.userAuthMessage[userID] = res.Message
s.mu.Unlock()
if res.Status == garmin.AuthSuccess {
if err := s.DB.MarkGarminConnected(ctx, userID); err != nil {
log.Printf("api: mark garmin connected for user %d: %v", userID, err)
}
}
}
func (s *Server) handleGarminAuthLogin(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
client, err := s.clientFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
res, err := client.Authenticate(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordAuthResult(r.Context(), userID, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
func (s *Server) handleGarminAuthMFA(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
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
}
client, err := s.clientFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
res, err := client.CompleteMFA(r.Context(), body.Code)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordAuthResult(r.Context(), userID, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
func (s *Server) handleGarminAuthStatus(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
s.mu.Lock()
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
s.mu.Unlock()
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
}
// handleGarminSyncRun does a full sync pass: Backfill first (resumes from the
// watermark, so widening the configured history horizon between clicks is
// picked up automatically), then IncrementalSync (catches anything new since
// the latest known activity), then fills in details for whatever's still
// missing them. Activities already fully processed are left untouched --
// see internal/sync.Service.FillPendingDetails. Recorded as a single
// FullSync run so "last sync" reports the combined activity count, not just
// whichever of Backfill/IncrementalSync happened to finish last.
func (s *Server) handleGarminSyncRun(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ok := s.backgroundSync(userID, func(ctx context.Context) error {
return svc.FullSync(ctx, detailFillBatchSize)
})
if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
}
// handleGarminSyncReset wipes every synced activity (and its laps/samples/kind
// assignments) and rewinds the backfill watermark, so the next Sync Now
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
// gates this behind a confirmation.
func (s *Server) handleGarminSyncReset(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ok := s.backgroundSync(userID, func(ctx context.Context) error {
return svc.ResetAll(ctx)
})
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) handleGarminSyncRuns(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, runs)
}
func (s *Server) handleGarminSyncStatus(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
pendingDetails, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
pendingWorkouts, err := s.DB.CountActivitiesMissingWorkout(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.mu.Lock()
inProgress := s.userSyncRunning[userID]
s.mu.Unlock()
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
progress := svc.Progress()
resp := map[string]any{
"in_progress": inProgress,
"progress": progress,
"activities_pending_details": pendingDetails,
"workouts_pending": pendingWorkouts,
}
if ok {
resp["last_run"] = run
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -10,9 +10,7 @@ import (
"geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
// doJSONAs is doJSON but for an explicit session Sub, for tests that need
@@ -150,8 +148,8 @@ func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.
t.Fatalf("store.Open: %v", err)
}
defer db.Close()
m := &mock.Client{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
m := &garmin.MockClient{}
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned
if rec.Code != http.StatusForbidden {
@@ -196,7 +194,7 @@ func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // as userA
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}

View File

@@ -61,7 +61,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
client, err := s.garminFor(r.Context(), userID)
client, err := s.clientFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -98,6 +98,6 @@ func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.removeGarminClient(userID)
s.removeUserClient(userID)
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -8,6 +8,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
applog "geniusrun/backend/internal/log"
"log"
"log/slog"
"net/http"
@@ -18,35 +19,34 @@ import (
"sync/atomic"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"geniusrun/backend/internal/applog"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// Server wires the HTTP handlers to the app's dependencies. garmin.Client
// and sync.Service are per-user (each user might have their own Garmin
// account), built lazily on first use via GarminFactory and cached.
type Server struct {
DB *store.DB
Auth auth.Verifier
Session SessionConfig
DB *store.DB
Auth auth.Verifier
SessionConfig SessionConfig
// GarminFactory builds a real (or fake, in tests) garmin.Client from a
// fully-resolved per-user Config. Production wiring passes
// garmin.NewClient; tests inject a factory returning a shared
// *mock.Client (see newTestServer in api_test.go).
GarminFactory func(garmin.Config) garmin.Client
// GarminBase holds the plumbing shared by every user's garmin.Config
GarminFactory func(garmin.ClientConfig) garmin.Client
// ClientConfig holds the plumbing shared by every user's garmin.Config
// (the python interpreter path + the token-store root directory); only
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
// garminFor.
GarminBase garmin.Config
SyncConfig appsync.Config
ClientConfig garmin.ClientConfig
SyncConfig garmin.SyncConfig
// EnvVars is the read-only, display-safe environment-configuration
// snapshot served by GET /api/config -- built once in main.go from
@@ -55,260 +55,36 @@ type Server struct {
EnvVars []EnvVar
mu sync.Mutex
userGarmin map[int64]garmin.Client
userSync map[int64]*appsync.Service
userClient map[int64]garmin.Client
userSync map[int64]*garmin.Sync
userAuthStatus map[int64]garmin.AuthStatus
userAuthMessage map[int64]string
userSyncRunning map[int64]bool
setupGarmin map[string]*setupSession
setupSession map[string]*setupSession
}
// NewServer builds a Server.
func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, garminBase garmin.Config, syncConfig appsync.Config, authVerifier auth.Verifier, session SessionConfig) *Server {
func NewServer(db *store.DB, garminFactory func(garmin.ClientConfig) garmin.Client, garminBase garmin.ClientConfig, syncConfig garmin.SyncConfig, authVerifier auth.Verifier, sessionConfig SessionConfig) *Server {
return &Server{
DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig,
Auth: authVerifier, Session: session,
userGarmin: map[int64]garmin.Client{},
userSync: map[int64]*appsync.Service{},
DB: db,
GarminFactory: garminFactory,
ClientConfig: garminBase,
SyncConfig: syncConfig,
Auth: authVerifier,
SessionConfig: sessionConfig,
userClient: map[int64]garmin.Client{},
userSync: map[int64]*garmin.Sync{},
userAuthStatus: map[int64]garmin.AuthStatus{},
userAuthMessage: map[int64]string{},
userSyncRunning: map[int64]bool{},
setupGarmin: map[string]*setupSession{},
setupSession: map[string]*setupSession{},
}
}
// setupSession is a temporary, not-yet-persisted Garmin authentication
// attempt made during onboarding, before any users/profile row exists --
// keyed by OIDC subject (the only stable identifier available pre-account)
// rather than a user id. Closed (never promoted into Server.userGarmin) once
// /api/setup/complete actually creates the account -- its Client's
// garmin.Config.TokenStorePath is permanently pinned to the ephemeral
// setup/{hash} directory, so reusing the object after that directory is
// renamed to the permanent {userID} one would respawn against a stale path
// the next time anything closes and restarts its subprocess; a later
// garminFor(ctx, userID) call builds a fresh client with the correct path
// instead. Otherwise evicted lazily (the next setup-endpoint touch for that
// subject checks staleness first) once idle past setupSessionIdleTimeout.
type setupSession struct {
Client garmin.Client
Email, Password string
Status garmin.AuthStatus
Message string
LastUsed time.Time
}
// setupSessionIdleTimeout bounds how long an onboarding Garmin session
// survives without being touched (login, MFA, or complete) before it's
// evicted -- long enough to check email for an MFA code, short enough that
// an abandoned attempt doesn't leave a subprocess running indefinitely.
const setupSessionIdleTimeout = 15 * time.Minute
// setupTokenStoreDir returns the token-store directory an ephemeral setup
// session for sub should use -- hashed rather than sub itself, since sub is
// an opaque string from the identity provider and using it verbatim in a
// filesystem path would be a directory-traversal risk if it ever contained
// path separators.
func setupTokenStoreDir(root, sub string) string {
h := sha256.Sum256([]byte(sub))
return filepath.Join(root, "setup", hex.EncodeToString(h[:]))
}
// setupSessionFor returns sub's in-progress ephemeral Garmin session, if
// any and not stale. A stale session is closed and evicted first, so the
// caller always either gets a fresh, live session or none.
func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.setupGarmin[sub]
if !ok {
return nil, false
}
if time.Since(sess.LastUsed) > setupSessionIdleTimeout {
sess.Client.Close()
delete(s.setupGarmin, sub)
if s.GarminBase.TokenStorePath != "" {
os.RemoveAll(setupTokenStoreDir(s.GarminBase.TokenStorePath, sub))
}
return nil, false
}
return sess, true
}
// replaceSetupSession closes and replaces sub's ephemeral Garmin session
// (if any) with a freshly built one for the given credentials -- same
// "close old, spawn new" semantics as garmin.Client.UpdateCredentials.
func (s *Server) replaceSetupSession(sub, email, password string) *setupSession {
s.mu.Lock()
old, ok := s.setupGarmin[sub]
s.mu.Unlock()
if ok {
old.Client.Close()
}
cfg := s.GarminBase
cfg.GarminEmail = email
cfg.GarminPassword = password
if cfg.TokenStorePath != "" {
cfg.TokenStorePath = setupTokenStoreDir(s.GarminBase.TokenStorePath, sub)
}
sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()}
s.mu.Lock()
s.setupGarmin[sub] = sess
s.mu.Unlock()
return sess
}
// recordSetupAuthResult updates sub's ephemeral session after a login or
// MFA attempt. A no-op if the session is gone (e.g. evicted concurrently).
func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) {
s.mu.Lock()
defer s.mu.Unlock()
if sess, ok := s.setupGarmin[sub]; ok {
sess.Status = res.Status
sess.Message = res.Message
sess.LastUsed = time.Now()
}
}
// removeSetupSession closes and drops sub's ephemeral Garmin session, if
// any, and best-effort removes its token-store directory. Used when
// abandoning onboarding (logout). handleSetupComplete closes the client the
// same way but keeps (renames) the directory instead of removing it, since
// that's the real, now-permanent session.
func (s *Server) removeSetupSession(sub string) {
s.mu.Lock()
sess, ok := s.setupGarmin[sub]
delete(s.setupGarmin, sub)
s.mu.Unlock()
if !ok {
return
}
sess.Client.Close()
if s.GarminBase.TokenStorePath != "" {
os.RemoveAll(setupTokenStoreDir(s.GarminBase.TokenStorePath, sub))
}
}
// garminFor returns userID's garmin.Client, building and caching it (from
// userID's own profile row) on first use.
func (s *Server) garminFor(ctx context.Context, userID int64) (garmin.Client, error) {
s.mu.Lock()
if c, ok := s.userGarmin[userID]; ok {
s.mu.Unlock()
return c, nil
}
s.mu.Unlock()
profile, err := s.DB.GetProfile(ctx, userID)
if err != nil {
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
}
cfg := s.GarminBase
cfg.GarminEmail = profile.GarminEmail
cfg.GarminPassword = profile.GarminPassword
if cfg.TokenStorePath != "" {
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
}
s.mu.Lock()
defer s.mu.Unlock()
if c, ok := s.userGarmin[userID]; ok {
return c, nil // built concurrently by another request between our unlock and re-lock
}
client := s.GarminFactory(cfg)
s.userGarmin[userID] = client
return client, nil
}
// syncFor returns userID's sync.Service, building and caching it on first use.
func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, error) {
s.mu.Lock()
if svc, ok := s.userSync[userID]; ok {
s.mu.Unlock()
return svc, nil
}
s.mu.Unlock()
client, err := s.garminFor(ctx, userID)
if err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if svc, ok := s.userSync[userID]; ok {
return svc, nil
}
svc := appsync.NewService(client, s.DB, userID, s.SyncConfig, nil)
s.userSync[userID] = svc
return svc, nil
}
// removeGarminClient drops userID's cached garmin.Client/sync.Service (if
// any) and every other per-user in-memory entry for userID, terminating the
// client's subprocess and best-effort removing its on-disk token-store
// directory. Called when a user's account has just been deleted from the
// DB, so nothing in memory keeps referencing a userID that no longer
// exists.
func (s *Server) removeGarminClient(userID int64) {
s.mu.Lock()
client, ok := s.userGarmin[userID]
delete(s.userGarmin, userID)
delete(s.userSync, userID)
delete(s.userAuthStatus, userID)
delete(s.userAuthMessage, userID)
delete(s.userSyncRunning, userID)
s.mu.Unlock()
if ok {
if err := client.Close(); err != nil {
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
}
}
if s.GarminBase.TokenStorePath == "" {
return
}
tokenStoreDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
if err := os.RemoveAll(tokenStoreDir); err != nil {
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
}
}
var requestIDCounter atomic.Int64
// requestLoggingMiddleware logs one JSON line per HTTP request (method,
// path, status, duration) and attaches a per-request logger (tagged with a
// request_id) to the request context, so any downstream call this request
// triggers -- e.g. a Garmin wrapper round-trip -- logs with the same
// correlating id (see internal/applog, internal/garmin's roundTrip).
func requestLoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := fmt.Sprintf("req-%d", requestIDCounter.Add(1))
logger := applog.FromContext(r.Context()).With("request_id", id)
r = r.WithContext(applog.WithLogger(r.Context(), logger))
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
level := slog.LevelInfo
if ww.Status() >= 500 {
level = slog.LevelWarn
}
logger.LogAttrs(r.Context(), level, "http request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", ww.Status()),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
)
})
}
// Router builds the HTTP routes.
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(requestLoggingMiddleware)
r.Use(loggingMiddleware)
r.Use(corsMiddleware)
r.Route("/api", func(r chi.Router) {
r.Get("/health", s.handleHealth)
@@ -319,11 +95,12 @@ func (s *Server) Router() http.Handler {
r.Get("/session/callback", s.handleSessionCallback)
r.Group(func(r chi.Router) {
r.Use(auth.RequireSession(s.Session.Secret))
r.Use(auth.RequireSession(s.SessionConfig.Secret))
r.Use(s.resolveUser)
r.Get("/session/me", s.handleSessionMe)
r.Post("/session/logout", s.handleSessionLogout)
r.Route("/setup", func(r chi.Router) {
r.Post("/complete", s.handleSetupComplete)
r.Route("/garmin", func(r chi.Router) {
@@ -341,17 +118,20 @@ func (s *Server) Router() http.Handler {
r.Delete("/", s.handleDeleteProfile)
})
r.Route("/auth", func(r chi.Router) {
r.Post("/login", s.handleAuthLogin)
r.Post("/mfa", s.handleAuthMFA)
r.Get("/status", s.handleAuthStatus)
})
r.Route("/garmin", func(r chi.Router) {
r.Route("/auth", func(r chi.Router) {
r.Post("/login", s.handleGarminAuthLogin)
r.Post("/mfa", s.handleGarminAuthMFA)
r.Get("/status", s.handleGarminAuthStatus)
})
r.Route("/sync", func(r chi.Router) {
r.Post("/run", s.handleGarminSyncRun)
r.Post("/reset", s.handleGarminSyncReset)
r.Get("/runs", s.handleGarminSyncRuns)
r.Get("/status", s.handleGarminSyncStatus)
})
r.Route("/sync", func(r chi.Router) {
r.Post("/run", s.handleSyncRun)
r.Post("/reset", s.handleSyncReset)
r.Get("/runs", s.handleSyncRuns)
r.Get("/status", s.handleSyncStatus)
})
r.Route("/activities", func(r chi.Router) {
@@ -379,6 +159,40 @@ func (s *Server) Router() http.Handler {
return r
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
var requestIDCounter atomic.Int64
// loggingMiddleware logs one JSON line per HTTP request (method,
// path, status, duration) and attaches a per-request logger (tagged with a
// request_id) to the request context, so any downstream call this request
// triggers -- e.g. a Garmin wrapper round-trip -- logs with the same
// correlating id (see internal/applog, internal/garmin's roundTrip).
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := fmt.Sprintf("req-%d", requestIDCounter.Add(1))
logger := applog.FromContext(r.Context()).With("request_id", id)
r = r.WithContext(applog.WithLogger(r.Context(), logger))
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
level := slog.LevelInfo
if ww.Status() >= 500 {
level = slog.LevelWarn
}
logger.LogAttrs(r.Context(), level, "http request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", ww.Status()),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
)
})
}
// corsMiddleware allows the frontend dev server (a different port) to call
// this API. Reflecting any origin back is safe even with credentials
// enabled: this remains a single-operator app whose real access control is
@@ -399,20 +213,89 @@ func corsMiddleware(next http.Handler) http.Handler {
})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
// clientFor returns userID's garmin.Client, building and caching it (from
// userID's own profile row) on first use.
func (s *Server) clientFor(ctx context.Context, userID int64) (garmin.Client, error) {
s.mu.Lock()
if c, ok := s.userClient[userID]; ok {
s.mu.Unlock()
return c, nil
}
s.mu.Unlock()
profile, err := s.DB.GetProfile(ctx, userID)
if err != nil {
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
}
cfg := s.ClientConfig
cfg.GarminEmail = profile.GarminEmail
cfg.GarminPassword = profile.GarminPassword
if cfg.TokenStorePath != "" {
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
}
s.mu.Lock()
defer s.mu.Unlock()
if c, ok := s.userClient[userID]; ok {
return c, nil // built concurrently by another request between our unlock and re-lock
}
client := s.GarminFactory(cfg)
s.userClient[userID] = client
return client, nil
}
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)
// removeUserClient drops userID's cached garmin.Client/sync.Service (if
// any) and every other per-user in-memory entry for userID, terminating the
// client's subprocess and best-effort removing its on-disk token-store
// directory. Called when a user's account has just been deleted from the
// DB, so nothing in memory keeps referencing a userID that no longer
// exists.
func (s *Server) removeUserClient(userID int64) {
s.mu.Lock()
client, ok := s.userClient[userID]
delete(s.userClient, userID)
delete(s.userSync, userID)
delete(s.userAuthStatus, userID)
delete(s.userAuthMessage, userID)
delete(s.userSyncRunning, userID)
s.mu.Unlock()
if ok {
if err := client.Close(); err != nil {
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
}
}
if s.ClientConfig.TokenStorePath == "" {
return
}
tokenStoreDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
if err := os.RemoveAll(tokenStoreDir); err != nil {
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
// syncFor returns userID's sync.Service, building and caching it on first use.
func (s *Server) syncFor(ctx context.Context, userID int64) (*garmin.Sync, error) {
s.mu.Lock()
if svc, ok := s.userSync[userID]; ok {
s.mu.Unlock()
return svc, nil
}
s.mu.Unlock()
client, err := s.clientFor(ctx, userID)
if err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if svc, ok := s.userSync[userID]; ok {
return svc, nil
}
svc := garmin.NewSync(client, s.DB, userID, s.SyncConfig, nil)
s.userSync[userID] = svc
return svc, nil
}
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
@@ -439,3 +322,25 @@ func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error
}()
return true
}
// setupTokenStoreDir returns the token-store directory an ephemeral setup
// session for sub should use -- hashed rather than sub itself, since sub is
// an opaque string from the identity provider and using it verbatim in a
// filesystem path would be a directory-traversal risk if it ever contained
// path separators.
func setupTokenStoreDir(root, sub string) string {
h := sha256.Sum256([]byte(sub))
return filepath.Join(root, "setup", hex.EncodeToString(h[:]))
}
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})
}

View File

@@ -45,7 +45,7 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadGateway, err.Error())
return
}
cookie, err := auth.MintTxnCookie(txn, s.Session.Secret, s.Session.Secure)
cookie, err := auth.MintTxnCookie(txn, s.SessionConfig.Secret, s.SessionConfig.Secure)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -58,36 +58,36 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
txnCookie, err := r.Cookie(auth.TxnCookieName)
if err != nil {
log.Printf("session callback: missing txn cookie: %v", err)
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return
}
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.Session.Secure))
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.SessionConfig.Secure))
txn, err := auth.ParseTxnCookie(txnCookie, s.Session.Secret)
txn, err := auth.ParseTxnCookie(txnCookie, s.SessionConfig.Secret)
if err != nil {
log.Printf("session callback: failed to parse txn cookie: %v", err)
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return
}
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
if err != nil {
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", err)
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return
}
if !result.Authorized {
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
return
}
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.Session.Secret, s.Session.Duration, s.Session.Secure)
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.SessionConfig.Secret, s.SessionConfig.Duration, s.SessionConfig.Secure)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
http.SetCookie(w, sessionCookie)
http.Redirect(w, r, s.Session.FrontendURL+"/", http.StatusFound)
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/", http.StatusFound)
}
// handleSessionLogout clears geniusrun's own session cookie and redirects
@@ -99,8 +99,8 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
claims, _ := auth.ClaimsFromContext(r.Context())
s.removeSetupSession(claims.Sub)
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/", claims.IDToken), http.StatusFound)
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.SessionConfig.Secure))
http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", claims.IDToken), http.StatusFound)
}
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {

View File

@@ -7,11 +7,38 @@ import (
"os"
"path/filepath"
"strconv"
"time"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin"
)
// setupSessionIdleTimeout bounds how long an onboarding Garmin session
// survives without being touched (login, MFA, or complete) before it's
// evicted -- long enough to check email for an MFA code, short enough that
// an abandoned attempt doesn't leave a subprocess running indefinitely.
const setupSessionIdleTimeout = 15 * time.Minute
// setupSession is a temporary, not-yet-persisted Garmin authentication
// attempt made during onboarding, before any users/profile row exists --
// keyed by OIDC subject (the only stable identifier available pre-account)
// rather than a user id. Closed (never promoted into Server.userClient) once
// /api/setup/complete actually creates the account -- its Client's
// garmin.Config.TokenStorePath is permanently pinned to the ephemeral
// setup/{hash} directory, so reusing the object after that directory is
// renamed to the permanent {userID} one would respawn against a stale path
// the next time anything closes and restarts its subprocess; a later
// garminFor(ctx, userID) call builds a fresh client with the correct path
// instead. Otherwise evicted lazily (the next setup-endpoint touch for that
// subject checks staleness first) once idle past setupSessionIdleTimeout.
type setupSession struct {
Client garmin.Client
Email, Password string
Status garmin.AuthStatus
Message string
LastUsed time.Time
}
// handleSetupGarminLogin authenticates against Garmin using an ephemeral,
// not-yet-persisted session keyed by the OIDC subject -- no users/profile
// row exists yet at this point (see setupSession in server.go).
@@ -90,7 +117,7 @@ func (s *Server) handleSetupGarminMFA(w http.ResponseWriter, r *http.Request) {
// garmin.AuthSuccess. Provisions the account, persists the Garmin
// credentials, marks it connected, closes the ephemeral client, and
// renames its token-store directory into the permanent per-user path --
// the next real garminFor(ctx, userID) builds a fresh client from scratch
// the next real clientFor(ctx, userID) builds a fresh client from scratch
// against that now-permanent directory, whose subprocess's lazy
// startup-login resumes the just-renamed, still-valid session without
// needing to re-authenticate (a cheap local token-store resume, not a
@@ -160,14 +187,14 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
sess.Client.Close()
s.mu.Lock()
delete(s.setupGarmin, claims.Sub)
delete(s.setupSession, claims.Sub)
s.userAuthStatus[userID] = sess.Status
s.userAuthMessage[userID] = sess.Message
s.mu.Unlock()
if s.GarminBase.TokenStorePath != "" {
oldDir := setupTokenStoreDir(s.GarminBase.TokenStorePath, claims.Sub)
newDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
if s.ClientConfig.TokenStorePath != "" {
oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub)
newDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
log.Printf("api: rename setup token store dir for user %d: %v", userID, err)
}
@@ -175,3 +202,80 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
}
// setupSessionFor returns sub's in-progress ephemeral Garmin session, if
// any and not stale. A stale session is closed and evicted first, so the
// caller always either gets a fresh, live session or none.
func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.setupSession[sub]
if !ok {
return nil, false
}
if time.Since(sess.LastUsed) > setupSessionIdleTimeout {
sess.Client.Close()
delete(s.setupSession, sub)
if s.ClientConfig.TokenStorePath != "" {
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
}
return nil, false
}
return sess, true
}
// replaceSetupSession closes and replaces sub's ephemeral Garmin session
// (if any) with a freshly built one for the given credentials -- same
// "close old, spawn new" semantics as garmin.Client.UpdateCredentials.
func (s *Server) replaceSetupSession(sub, email, password string) *setupSession {
s.mu.Lock()
old, ok := s.setupSession[sub]
s.mu.Unlock()
if ok {
old.Client.Close()
}
cfg := s.ClientConfig
cfg.GarminEmail = email
cfg.GarminPassword = password
if cfg.TokenStorePath != "" {
cfg.TokenStorePath = setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub)
}
sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()}
s.mu.Lock()
s.setupSession[sub] = sess
s.mu.Unlock()
return sess
}
// recordSetupAuthResult updates sub's ephemeral session after a login or
// MFA attempt. A no-op if the session is gone (e.g. evicted concurrently).
func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) {
s.mu.Lock()
defer s.mu.Unlock()
if sess, ok := s.setupSession[sub]; ok {
sess.Status = res.Status
sess.Message = res.Message
sess.LastUsed = time.Now()
}
}
// removeSetupSession closes and drops sub's ephemeral Garmin session, if
// any, and best-effort removes its token-store directory. Used when
// abandoning onboarding (logout). handleSetupComplete closes the client the
// same way but keeps (renames) the directory instead of removing it, since
// that's the real, now-permanent session.
func (s *Server) removeSetupSession(sub string) {
s.mu.Lock()
sess, ok := s.setupSession[sub]
delete(s.setupSession, sub)
s.mu.Unlock()
if !ok {
return
}
sess.Client.Close()
if s.ClientConfig.TokenStorePath != "" {
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
}
}

View File

@@ -10,25 +10,23 @@ import (
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
// newUnprovisionedServer builds a Server whose "test-user" OIDC subject
// (the identity doJSON's cookie always mints) has no users/profile row yet
// -- every test in this file needs that starting state, unlike
// newTestServer's auto-provisioned default.
func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *mock.Client) {
func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *garmin.MockClient) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &mock.Client{}
garminFactory := func(garmin.Config) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
m := &garmin.MockClient{}
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
return s, db, m
}
@@ -98,14 +96,14 @@ func TestSetupComplete_CreatesAccountWithGarminCredentialsAndClosesEphemeralClie
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &mock.Client{}
m := &garmin.MockClient{}
tokenStoreRoot := t.TempDir()
var factoryConfigs []garmin.Config
garminFactory := func(cfg garmin.Config) garmin.Client {
var factoryConfigs []garmin.ClientConfig
garminFactory := func(cfg garmin.ClientConfig) garmin.Client {
factoryConfigs = append(factoryConfigs, cfg)
return m
}
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
@@ -145,7 +143,7 @@ func TestSetupComplete_CreatesAccountWithGarminCredentialsAndClosesEphemeralClie
// A later real use must build a genuinely fresh client, configured
// against the permanent {userID} token store directory -- never the
// stale ephemeral setup/{hash} one the closed client was carrying.
if _, err := s.garminFor(newCtx(), u.ID); err != nil {
if _, err := s.clientFor(newCtx(), u.ID); err != nil {
t.Fatalf("garminFor: %v", err)
}
wantTokenStorePath := filepath.Join(tokenStoreRoot, itoa(u.ID))
@@ -210,9 +208,9 @@ func TestSetupComplete_RenamesTokenStoreDirectory(t *testing.T) {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &mock.Client{}
m := &garmin.MockClient{}
tokenStoreRoot := t.TempDir()
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{

View File

@@ -1,108 +0,0 @@
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
// handleSyncRun does a full sync pass: Backfill first (resumes from the
// watermark, so widening the configured history horizon between clicks is
// picked up automatically), then IncrementalSync (catches anything new since
// the latest known activity), then fills in details for whatever's still
// missing them. Activities already fully processed are left untouched --
// see internal/sync.Service.FillPendingDetails. Recorded as a single
// FullSync run so "last sync" reports the combined activity count, not just
// whichever of Backfill/IncrementalSync happened to finish last.
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ok := s.backgroundSync(userID, func(ctx context.Context) error {
return svc.FullSync(ctx, detailFillBatchSize)
})
if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
}
// handleSyncReset wipes every synced activity (and its laps/samples/kind
// assignments) and rewinds the backfill watermark, so the next Sync Now
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
// gates this behind a confirmation.
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ok := s.backgroundSync(userID, func(ctx context.Context) error {
return svc.ResetAll(ctx)
})
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) {
userID := userIDFromContext(r.Context())
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 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) {
userID := userIDFromContext(r.Context())
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
pendingDetails, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
pendingWorkouts, err := s.DB.CountActivitiesMissingWorkout(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.mu.Lock()
inProgress := s.userSyncRunning[userID]
s.mu.Unlock()
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
progress := svc.Progress()
resp := map[string]any{
"in_progress": inProgress,
"progress": progress,
"activities_pending_details": pendingDetails,
"workouts_pending": pendingWorkouts,
}
if ok {
resp["last_run"] = run
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -9,9 +9,7 @@ import (
"geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
@@ -41,8 +39,8 @@ func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &mock.Client{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
m := &garmin.MockClient{}
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
var gotOK bool
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -63,7 +63,7 @@ type AppConfig struct {
// LoadApp merges overrides (store.ConfigValues' map) over defaults. Pure
// -- it takes the raw map instead of a *store.DB so this package needs no
// store dependency and the merge logic tests without a database. A bad
// stored value fails fast, same posture as Load() for env vars.
// stored value fails fast, same posture as LoadEnv() for env vars.
func LoadApp(overrides map[string]string) (AppConfig, error) {
merged := map[string]string{}
for _, k := range appRegistry {

View File

@@ -53,8 +53,8 @@ func TestValidateAppValue(t *testing.T) {
}
func TestDisplayEnv_MasksSecrets(t *testing.T) {
cfg := Config{
Addr: ":8080", OIDCClientSecret: "hunter2",
cfg := EnvConfig{
BackendAddr: ":8080", OIDCClientSecret: "hunter2",
SessionSecret: []byte("0123456789abcdef0123456789abcdef"),
}
entries := map[string]string{}
@@ -70,7 +70,7 @@ func TestDisplayEnv_MasksSecrets(t *testing.T) {
if entries["GENIUSRUN_SESSION_SECRET"] != "•••• (set)" {
t.Errorf("session secret not masked: %q", entries["GENIUSRUN_SESSION_SECRET"])
}
empty := Config{}
empty := EnvConfig{}
for _, e := range empty.DisplayEnv() {
if e.Name == "GENIUSRUN_OIDC_CLIENT_SECRET" && e.Value != "(unset)" {
t.Errorf("unset secret = %q, want (unset)", e.Value)

View File

@@ -11,18 +11,18 @@ import (
"strings"
)
// Config holds geniusrund's process-level configuration.
type Config struct {
// Addr is the HTTP listen address, e.g. ":8080".
Addr string
// EnvConfig holds geniusrund's process-level configuration.
type EnvConfig struct {
// BackendAddr is the HTTP listen address, e.g. ":8080".
BackendAddr string
// DBPath is the SQLite database file path.
DBPath string
// GarminPythonPath is the python3 interpreter used to run the embedded
// PythonPath is the python3 interpreter used to run the embedded
// Garmin wrapper script (internal/garmin's go:embed'd wrapper.py).
// Defaults to "python3" resolved via PATH if unset.
GarminPythonPath string
// GarminTokenStoreRoot is the root directory under which each user's
PythonPath string
// TokenStoreRoot is the root directory under which each user's
// Garmin session cache lives (one subdirectory per user id, e.g.
// "<root>/3"). Read from the GENIUSRUN_TOKENSTORE_PATH env var, configured
// independently of DBPath -- if unset, it defaults to a ".garmin"
@@ -31,7 +31,7 @@ type Config struct {
// per-user isolation automatically -- multi-tenant operation always
// relies on this being a real, distinct-per-user path (see
// api.Server.garminFor), so it can never be silently left empty.
GarminTokenStoreRoot string
TokenStoreRoot string
// LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error").
LogLevel string
@@ -62,20 +62,20 @@ type Config struct {
SessionSecure bool
}
// Load reads configuration from environment variables, applying defaults
// LoadEnv 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("GENIUSRUN_BACKEND_ADDR", ":8080"),
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
GarminPythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"),
GarminTokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"),
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
func LoadEnv() (EnvConfig, error) {
cfg := EnvConfig{
BackendAddr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"),
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
PythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"),
TokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"),
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
}
if cfg.BackendURL == "" {
@@ -121,7 +121,7 @@ type EnvEntry struct {
// DisplayEnv returns the environment configuration as a display-safe
// list, in stable order, secrets masked.
func (c Config) DisplayEnv() []EnvEntry {
func (c EnvConfig) DisplayEnv() []EnvEntry {
mask := func(set bool) string {
if set {
return "•••• (set)"
@@ -129,10 +129,10 @@ func (c Config) DisplayEnv() []EnvEntry {
return "(unset)"
}
return []EnvEntry{
{Name: "GENIUSRUN_BACKEND_ADDR", Value: c.Addr},
{Name: "GENIUSRUN_BACKEND_ADDR", Value: c.BackendAddr},
{Name: "GENIUSRUN_DB_PATH", Value: c.DBPath},
{Name: "GENIUSRUN_PYTHON_PATH", Value: c.GarminPythonPath},
{Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.GarminTokenStoreRoot},
{Name: "GENIUSRUN_PYTHON_PATH", Value: c.PythonPath},
{Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.TokenStoreRoot},
{Name: "GENIUSRUN_LOG_LEVEL", Value: c.LogLevel},
{Name: "GENIUSRUN_BACKEND_URL", Value: c.BackendURL},
{Name: "GENIUSRUN_FRONTEND_URL", Value: c.FrontendURL},

View File

@@ -16,7 +16,7 @@ func setRequiredEnv(t *testing.T) {
func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) {
setRequiredEnv(t)
cfg, err := Load()
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
@@ -35,7 +35,7 @@ func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_BACKEND_URL", "http://localhost:8080")
cfg, err := Load()
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
@@ -58,7 +58,7 @@ func TestLoad_MissingRequiredOIDCVars(t *testing.T) {
t.Run(missing, func(t *testing.T) {
setRequiredEnv(t)
t.Setenv(missing, "")
if _, err := Load(); err == nil {
if _, err := LoadEnv(); err == nil {
t.Fatalf("expected error when %s is unset", missing)
}
})
@@ -69,7 +69,7 @@ func TestLoad_SessionSecretTooShort(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_SESSION_SECRET", "too-short")
if _, err := Load(); err == nil {
if _, err := LoadEnv(); err == nil {
t.Fatal("expected error for a session secret under 32 characters")
}
}
@@ -79,12 +79,12 @@ func TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath(t *testing.T) {
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "")
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
cfg, err := Load()
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.GarminTokenStoreRoot != ".garmin" {
t.Errorf("GarminTokenStoreRoot = %q, want %q (never derived from DBPath's directory)", cfg.GarminTokenStoreRoot, ".garmin")
if cfg.TokenStoreRoot != ".garmin" {
t.Errorf("GarminTokenStoreRoot = %q, want %q (never derived from DBPath's directory)", cfg.TokenStoreRoot, ".garmin")
}
}
@@ -93,12 +93,12 @@ func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "/custom/tokenstores")
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
cfg, err := Load()
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.GarminTokenStoreRoot != "/custom/tokenstores" {
t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.GarminTokenStoreRoot)
if cfg.TokenStoreRoot != "/custom/tokenstores" {
t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.TokenStoreRoot)
}
}
@@ -106,7 +106,7 @@ func TestLoad_LogLevelDefaultsToInfo(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_LOG_LEVEL", "")
cfg, err := Load()
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
@@ -119,7 +119,7 @@ func TestLoad_LogLevelExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_LOG_LEVEL", "debug")
cfg, err := Load()
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
@@ -132,12 +132,12 @@ func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_PYTHON_PATH", "")
cfg, err := Load()
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.GarminPythonPath != "python3" {
t.Errorf("GarminPythonPath = %q, want default %q", cfg.GarminPythonPath, "python3")
if cfg.PythonPath != "python3" {
t.Errorf("GarminPythonPath = %q, want default %q", cfg.PythonPath, "python3")
}
}
@@ -145,12 +145,12 @@ func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_PYTHON_PATH", "/opt/venv/bin/python3")
cfg, err := Load()
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.GarminPythonPath != "/opt/venv/bin/python3" {
t.Errorf("GarminPythonPath = %q, want explicit override", cfg.GarminPythonPath)
if cfg.PythonPath != "/opt/venv/bin/python3" {
t.Errorf("GarminPythonPath = %q, want explicit override", cfg.PythonPath)
}
}
@@ -158,7 +158,7 @@ func TestLoad_FrontendURLDefaultsToBackendURL(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_FRONTEND_URL", "")
cfg, err := Load()
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
@@ -171,7 +171,7 @@ func TestLoad_FrontendURLExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_FRONTEND_URL", "http://localhost:5173/")
cfg, err := Load()
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
@@ -184,7 +184,7 @@ func TestLoad_CustomRole(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
cfg, err := Load()
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}

View File

@@ -1,5 +1,5 @@
// Package garmin wraps a direct garminconnect subprocess (see
// pyscript/wrapper.py) as a narrow Go client interface, so the rest of
// wrapper/wrapper.py) as a narrow Go client interface, so the rest of
// geniusrun never deals with the wire protocol directly.
package garmin
@@ -18,10 +18,10 @@ import (
"sync"
"time"
"geniusrun/backend/internal/applog"
"geniusrun/backend/internal/log"
)
//go:embed pyscript/wrapper.py
//go:embed wrapper/wrapper.py
var wrapperScript string
// maxWrapperLineBytes bounds one JSON-line response from the wrapper
@@ -57,8 +57,8 @@ type Client interface {
Close() error
}
// Config configures how the Garmin wrapper subprocess is spawned.
type Config struct {
// ClientConfig configures how the Garmin wrapper subprocess is spawned.
type ClientConfig struct {
// PythonPath is the python3 interpreter to run the embedded wrapper
// script with. Empty defaults to "python3" resolved via PATH.
PythonPath string
@@ -121,7 +121,7 @@ func mapAuthStatus(s string) AuthStatus {
// subprocessClient is the real Client implementation, backed by a wrapper
// subprocess spoken to over newline-delimited JSON on stdio.
type subprocessClient struct {
cfg Config
cfg ClientConfig
mu sync.Mutex // serializes calls; the wrapper holds a single shared Garmin client
cmd *exec.Cmd
@@ -366,7 +366,7 @@ var _ Client = (*subprocessClient)(nil)
// NewClient builds a Client. The subprocess is not spawned until the first
// call that needs it (Authenticate, or any data call once authenticated).
func NewClient(cfg Config) Client {
func NewClient(cfg ClientConfig) Client {
return &subprocessClient{cfg: cfg}
}

View File

@@ -13,7 +13,7 @@ import (
"testing"
"time"
"geniusrun/backend/internal/applog"
"geniusrun/backend/internal/log"
)
// wireResponsePayload is what a fake wrapper handler returns for one
@@ -166,7 +166,7 @@ func TestSubprocessClient_RoundTrip_OrdinaryErrorDoesNotWrapErrNotFound(t *testi
}
func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
c := &subprocessClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}}
c := &subprocessClient{cfg: ClientConfig{GarminEmail: "old@example.com", GarminPassword: "old"}}
c.started = true // simulate an already-spawned subprocess, no real cmd/pipes
c.UpdateCredentials("new@example.com", "new")

View File

@@ -1,4 +1,4 @@
package sync
package garmin
import (
"encoding/json"
@@ -6,7 +6,6 @@ import (
"time"
"geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
)
@@ -19,7 +18,7 @@ func isRunningActivityType(typeKey string) bool {
return strings.Contains(strings.ToLower(typeKey), "run")
}
func toActivityRow(a garmin.Activity) store.Activity {
func toActivityRow(a Activity) store.Activity {
return store.Activity{
GarminActivityID: a.ActivityID,
EventTypeKey: a.EventType.TypeKey,
@@ -52,7 +51,7 @@ func nonZero(v float64) *float64 {
// 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, targets []*garmin.WorkoutStep, profile store.Profile) []store.Lap {
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 {
@@ -110,9 +109,9 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
// 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 garmin.Workout) []*garmin.WorkoutStep {
func alignWorkoutTargets(lapCount int, workout Workout) []*WorkoutStep {
steps := workout.FlattenSteps()
out := make([]*garmin.WorkoutStep, lapCount)
out := make([]*WorkoutStep, lapCount)
switch lapCount - len(steps) {
case 0, 1:
@@ -126,7 +125,7 @@ func alignWorkoutTargets(lapCount int, workout garmin.Workout) []*garmin.Workout
// 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 garmin.WorkoutStep) (*float64, *float64) {
func targetPaceRange(step WorkoutStep) (*float64, *float64) {
if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil {
return nil, nil
}
@@ -141,7 +140,7 @@ func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
// 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 garmin.WorkoutStep, profile store.Profile) (*float64, *float64) {
func targetHRRange(step WorkoutStep, profile store.Profile) (*float64, *float64) {
if step.TargetType.TypeKey != "heart.rate.zone" {
return nil, nil
}
@@ -188,7 +187,7 @@ func karvonenBounds(p store.Profile, zone int) (lowBpm, highBpm float64, ok bool
return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true
}
func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.SampleInfo {
func samplesInWindow(samples []Sample, start, end float64) []classify.SampleInfo {
var out []classify.SampleInfo
for _, s := range samples {
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
@@ -199,7 +198,7 @@ func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.Sam
return out
}
func toSampleRows(samples []garmin.Sample) []store.Sample {
func toSampleRows(samples []Sample) []store.Sample {
rows := make([]store.Sample, len(samples))
for i, s := range samples {
rows[i] = store.Sample{

View File

@@ -1,21 +1,19 @@
// Package mock provides a fake garmin.Client for tests and frontend/dev
// MockClient support: a fake Client for tests and frontend/dev
// work without a live Garmin account or the wrapper subprocess.
package mock
package garmin
import (
"context"
"time"
"geniusrun/backend/internal/garmin"
)
// Client is a fake garmin.Client returning data supplied by the test/caller.
type Client struct {
AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls
Activities []garmin.Activity
Splits map[int64]garmin.ActivitySplits
Details map[int64]garmin.ActivityDetails
Workouts map[int64]garmin.Workout
// MockClient is a fake Client returning data supplied by the test/caller.
type MockClient struct {
AuthResults []AuthResult // consumed in order by Authenticate/CompleteMFA calls
Activities []Activity
Splits map[int64]ActivitySplits
Details map[int64]ActivityDetails
Workouts map[int64]Workout
// WorkoutErrByID, if set for a given workout ID, makes GetWorkoutByID
// return that error for that ID specifically -- independent of the
// all-calls-fail Err field below -- so a test can simulate one
@@ -37,33 +35,33 @@ type Client struct {
LastPassword string
}
var _ garmin.Client = (*Client)(nil)
var _ Client = (*MockClient)(nil)
func (c *Client) nextAuthResult() garmin.AuthResult {
func (c *MockClient) nextAuthResult() AuthResult {
if c.authResultCursor >= len(c.AuthResults) {
return garmin.AuthResult{Status: garmin.AuthSuccess, Message: "Authenticated successfully."}
return AuthResult{Status: AuthSuccess, Message: "Authenticated successfully."}
}
r := c.AuthResults[c.authResultCursor]
c.authResultCursor++
return r
}
func (c *Client) Authenticate(ctx context.Context) (garmin.AuthResult, error) {
func (c *MockClient) Authenticate(ctx context.Context) (AuthResult, error) {
c.AuthenticateCalls++
if c.Err != nil {
return garmin.AuthResult{}, c.Err
return AuthResult{}, c.Err
}
return c.nextAuthResult(), nil
}
func (c *Client) CompleteMFA(ctx context.Context, code string) (garmin.AuthResult, error) {
func (c *MockClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
if c.Err != nil {
return garmin.AuthResult{}, c.Err
return AuthResult{}, c.Err
}
return c.nextAuthResult(), nil
}
func (c *Client) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]garmin.Activity, error) {
func (c *MockClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
c.GetActivitiesCalls++
if c.Delay > 0 {
select {
@@ -81,36 +79,36 @@ func (c *Client) GetActivities(ctx context.Context, startDate, endDate string, l
return c.Activities, nil
}
func (c *Client) GetActivitySplits(ctx context.Context, activityID int64) (garmin.ActivitySplits, error) {
func (c *MockClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
if c.Err != nil {
return garmin.ActivitySplits{}, c.Err
return ActivitySplits{}, c.Err
}
return c.Splits[activityID], nil
}
func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (garmin.ActivityDetails, error) {
func (c *MockClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
if c.Err != nil {
return garmin.ActivityDetails{}, c.Err
return ActivityDetails{}, c.Err
}
return c.Details[activityID], nil
}
func (c *Client) GetWorkoutByID(ctx context.Context, workoutID int64) (garmin.Workout, error) {
func (c *MockClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
if c.Err != nil {
return garmin.Workout{}, c.Err
return Workout{}, c.Err
}
if err, ok := c.WorkoutErrByID[workoutID]; ok {
return garmin.Workout{}, err
return Workout{}, err
}
return c.Workouts[workoutID], nil
}
func (c *Client) UpdateCredentials(email, password string) {
func (c *MockClient) UpdateCredentials(email, password string) {
c.LastEmail = email
c.LastPassword = password
}
func (c *Client) Close() error {
func (c *MockClient) Close() error {
c.ClosedCalled = true
return nil
}

View File

@@ -1,8 +1,8 @@
// Package sync orchestrates fetching activities from Garmin (via
// Package garmin orchestrates fetching activities from Garmin (via
// internal/garmin), persisting them (via internal/store), and classifying
// them (via internal/classify). It's the only package that depends on all
// three, keeping garmin/store/classify decoupled from each other.
package sync
package garmin
import (
"context"
@@ -14,15 +14,14 @@ import (
"time"
"geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
)
// Config tunes sync behavior. Zero values fall back to sensible defaults in
// NewService. How far back Backfill reaches is not here -- it's
// NewSync. How far back Backfill reaches is not here -- it's
// Profile.BackfillHorizonDays, read fresh on every call so a user-edited
// value takes effect on the next sync without a server restart.
type Config struct {
type SyncConfig struct {
// BackfillWindowDays is the page size for each get_activities call
// during backfill.
BackfillWindowDays int
@@ -39,7 +38,7 @@ type Config struct {
MinConfidence float64
}
func (c Config) withDefaults() Config {
func (c SyncConfig) withDefaults() SyncConfig {
if c.BackfillWindowDays == 0 {
c.BackfillWindowDays = 90
}
@@ -76,37 +75,37 @@ type Progress struct {
Total int
}
// Service is the sync orchestrator, scoped to one user -- every store call
// Sync is the sync orchestrator, scoped to one user -- every store call
// it makes is for userID's data only.
type Service struct {
garmin garmin.Client
type Sync struct {
garmin Client
db *store.DB
userID int64
cfg Config
cfg SyncConfig
now func() time.Time
progressMu sync.Mutex
progress Progress
}
// NewService builds a Service scoped to userID. now defaults to time.Now if
// NewSync builds a Sync scoped to userID. now defaults to time.Now if
// nil (tests can override it for deterministic date windows).
func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service {
func NewSync(g Client, db *store.DB, userID int64, cfg SyncConfig, now func() time.Time) *Sync {
if now == nil {
now = time.Now
}
return &Service{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now, progress: Progress{Phase: PhaseIdle}}
return &Sync{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now, progress: Progress{Phase: PhaseIdle}}
}
// Progress returns the current sync progress (phase idle, 0/0 when nothing
// is running).
func (s *Service) Progress() Progress {
func (s *Sync) Progress() Progress {
s.progressMu.Lock()
defer s.progressMu.Unlock()
return s.progress
}
func (s *Service) setProgress(phase string, done, total int) {
func (s *Sync) setProgress(phase string, done, total int) {
s.progressMu.Lock()
s.progress = Progress{Phase: phase, Done: done, Total: total}
s.progressMu.Unlock()
@@ -126,7 +125,7 @@ func (s *Service) setProgress(phase string, done, total int) {
// combined SyncRun; there is no standalone entrypoint for this anymore
// (the periodic background sync loop that used to call one is gone -- see
// 4d2cbe4 refactor: remove automatic background incremental sync).
func (s *Service) backfillCore(ctx context.Context) (int, error) {
func (s *Sync) backfillCore(ctx context.Context) (int, error) {
profile, err := s.db.GetProfile(ctx, s.userID)
if err != nil {
return 0, fmt.Errorf("load profile: %w", err)
@@ -194,7 +193,7 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
// activity (or a short recent window if none exist yet) through today. Used
// by FullSync as one step of its single combined SyncRun -- see
// backfillCore's comment for why there's no standalone entrypoint.
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
func (s *Sync) incrementalSyncCore(ctx context.Context) (int, error) {
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok {
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
@@ -210,7 +209,7 @@ func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
// new since the latest known activity), then FillPendingDetails --
// recorded as a single SyncRun so the reported activity count covers the
// whole action instead of only whichever stage happened to finish last.
func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
func (s *Sync) FullSync(ctx context.Context, detailFillLimit int) error {
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull)
if err != nil {
return err
@@ -249,7 +248,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
// assignments) and rewinds the backfill watermark, so the next Backfill
// call performs a genuinely fresh pull from Garmin instead of resuming from
// wherever the previous one left off. Workout kinds are left untouched.
func (s *Service) ResetAll(ctx context.Context) error {
func (s *Sync) ResetAll(ctx context.Context) error {
return s.db.ResetAllSyncedData(ctx, s.userID)
}
@@ -265,7 +264,7 @@ func (s *Service) ResetAll(ctx context.Context) error {
// produced a confusing, meaningless number (e.g. "2 activities" on a sync
// that found nothing new, just because 2 already-known activities happened
// to fall inside the queried window).
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
func (s *Sync) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
if err != nil {
return 0, 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
@@ -297,7 +296,7 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st
// (re)classifies every activity touched by the first pass. Each pass makes
// its Garmin calls sequentially with Config.InterCallDelay between them to
// avoid Garmin/Cloudflare rate limiting.
func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
func (s *Sync) FillPendingDetails(ctx context.Context, limit int) error {
defer s.setProgress(PhaseIdle, 0, 0)
if err := s.fillPendingActivityDetails(ctx, limit); err != nil {
@@ -306,7 +305,7 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
return s.fillPendingWorkouts(ctx, limit)
}
func (s *Service) fillPendingActivityDetails(ctx context.Context, limit int) error {
func (s *Sync) fillPendingActivityDetails(ctx context.Context, limit int) error {
pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit)
if err != nil {
return err
@@ -343,7 +342,7 @@ func (s *Service) fillPendingActivityDetails(ctx context.Context, limit int) err
// bands are enrichment, not core activity data, and workout_raw_json
// staying NULL means ActivitiesMissingWorkout will naturally retry it on
// the next sync.
func (s *Service) fillPendingWorkouts(ctx context.Context, limit int) error {
func (s *Sync) fillPendingWorkouts(ctx context.Context, limit int) error {
pending, err := s.db.ActivitiesMissingWorkout(ctx, s.userID, limit)
if err != nil {
return err
@@ -364,7 +363,7 @@ func (s *Service) fillPendingWorkouts(ctx context.Context, limit int) error {
}
}
if err := s.fillActivityWorkout(ctx, a, profile); err != nil {
if errors.Is(err, garmin.ErrNotFound) {
if errors.Is(err, ErrNotFound) {
// A definitive 404 (the workout was deleted on Garmin's side
// after being linked to this activity) will never succeed on
// retry -- mark it so ActivitiesMissingWorkout stops
@@ -382,7 +381,7 @@ func (s *Service) fillPendingWorkouts(ctx context.Context, limit int) error {
return nil
}
func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
func (s *Sync) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID)
if err != nil {
return fmt.Errorf("get_activity_splits: %w", err)
@@ -392,14 +391,14 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
return fmt.Errorf("get_activity_details: %w", err)
}
samples := garmin.ExtractSamples(details)
samples := ExtractSamples(details)
if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil {
return err
}
// No workout-target alignment here -- that's fillActivityWorkout's job,
// run as its own later pass (see fillPendingWorkouts). targets is an
// all-nil placeholder the same length as splits.Laps.
targets := make([]*garmin.WorkoutStep, len(splits.Laps))
targets := make([]*WorkoutStep, len(splits.Laps))
if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
return err
}
@@ -416,7 +415,7 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
// fillActivityDetails, in some earlier pass or run) rather than needing the
// original garmin.Lap data again, since alignWorkoutTargets only needs a
// count.
func (s *Service) fillActivityWorkout(ctx context.Context, a store.Activity, profile store.Profile) error {
func (s *Sync) fillActivityWorkout(ctx context.Context, a store.Activity, profile store.Profile) error {
workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID)
if err != nil {
return fmt.Errorf("get_workout_by_id: %w", err)
@@ -441,7 +440,7 @@ func (s *Service) fillActivityWorkout(ctx context.Context, a store.Activity, pro
// ClassifyActivity (re)runs the rule engine for one activity against the
// currently active workout kinds and appends a new kind_assignments row.
// Safe to call repeatedly (e.g. after editing a workout kind's rule).
func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error {
func (s *Sync) ClassifyActivity(ctx context.Context, activityID int64) error {
activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID)
if err != nil {
return err

View File

@@ -1,4 +1,4 @@
package sync
package garmin
import (
"context"
@@ -8,13 +8,9 @@ import (
"time"
"geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
)
func f(v float64) *float64 { return &v }
func openTestDB(t *testing.T) *store.DB {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
@@ -58,11 +54,11 @@ func TestBackfillCore_StoresActivities(t *testing.T) {
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
m := &MockClient{Activities: []Activity{
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: ActivityType{TypeKey: "running"},
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
}}
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
total, err := svc.backfillCore(ctx)
if err != nil {
@@ -85,11 +81,11 @@ func TestBackfillCore_StoresActivities(t *testing.T) {
}
func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) {
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}}
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "no.target"}},
laps := []Lap{{LapIndex: 1}, {LapIndex: 2}}
workout := Workout{Segments: []WorkoutSegment{
{Steps: []WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "no.target"}},
}},
}}
@@ -111,11 +107,11 @@ func TestAlignWorkoutTargets_OneExtraTrailingLapKeepsOtherTargets(t *testing.T)
// 5-minute prescribed cool-down followed by another 6:46 the athlete
// just kept running). The extra lap should have no target, but every
// other lap's real target must still come through.
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(2), TargetValueTwo: f(2.5)},
laps := []Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
workout := Workout{Segments: []WorkoutSegment{
{Steps: []WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(2), TargetValueTwo: f(2.5)},
}},
}}
@@ -135,9 +131,9 @@ func TestAlignWorkoutTargets_OneExtraTrailingLapKeepsOtherTargets(t *testing.T)
}
func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
laps := []Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
workout := Workout{Segments: []WorkoutSegment{
{Steps: []WorkoutStep{
{Type: "ExecutableStepDTO"},
}},
}}
@@ -154,22 +150,22 @@ func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
}
func TestTargetPaceRange_OnlyForPaceZoneAndOrdersLowHigh(t *testing.T) {
lo, hi := targetPaceRange(garmin.WorkoutStep{
TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(4), TargetValueTwo: f(3),
lo, hi := targetPaceRange(WorkoutStep{
TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(4), TargetValueTwo: f(3),
})
if lo == nil || hi == nil || *lo != 3 || *hi != 4 {
t.Errorf("targetPaceRange = (%v, %v), want (3, 4) reordered", lo, hi)
}
lo, hi = targetPaceRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)})
lo, hi = targetPaceRange(WorkoutStep{TargetType: WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)})
if lo != nil || hi != nil {
t.Errorf("targetPaceRange for a non-pace step = (%v, %v), want (nil, nil)", lo, hi)
}
}
func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
lo, hi := targetHRRange(garmin.WorkoutStep{
TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(160), TargetValueTwo: f(150),
lo, hi := targetHRRange(WorkoutStep{
TargetType: WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(160), TargetValueTwo: f(150),
}, store.Profile{})
if lo == nil || hi == nil || *lo != 150 || *hi != 160 {
t.Errorf("custom bpm range = (%v, %v), want (150, 160) reordered", lo, hi)
@@ -180,13 +176,13 @@ func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
MaxHeartRate: f(190), RestingHeartRate: f(50),
HRZone3MinPct: 70, HRZone3MaxPct: 80,
}
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, profile)
lo, hi = targetHRRange(WorkoutStep{TargetType: WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, profile)
// Karvonen: restHR + pct * (maxHR - restHR) = 50 + 0.70*140 = 148, 50 + 0.80*140 = 162
if lo == nil || hi == nil || *lo != 148 || *hi != 162 {
t.Errorf("zone-based bpm range = (%v, %v), want (148, 162)", lo, hi)
}
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, store.Profile{})
lo, hi = targetHRRange(WorkoutStep{TargetType: WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, store.Profile{})
if lo != nil || hi != nil {
t.Errorf("zone-based range without max/resting HR configured = (%v, %v), want (nil, nil)", lo, hi)
}
@@ -197,17 +193,17 @@ func TestBackfillCore_SkipsNonRunningActivities(t *testing.T) {
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"},
m := &MockClient{Activities: []Activity{
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"},
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "trail_running"},
{ActivityID: 2, ActivityType: ActivityType{TypeKey: "trail_running"},
StartTimeGMT: "2026-07-02 06:00:00", Distance: 8000, Duration: 2400},
{ActivityID: 3, ActivityType: garmin.ActivityType{TypeKey: "paddelball"},
{ActivityID: 3, ActivityType: ActivityType{TypeKey: "paddelball"},
StartTimeGMT: "2026-07-03 06:00:00", Distance: 0, Duration: 1800},
{ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"},
{ActivityID: 4, ActivityType: ActivityType{TypeKey: "indoor_cycling"},
StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800},
}}
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if _, err := svc.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore: %v", err)
@@ -233,27 +229,27 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
userID := provisionTestUser(t, db)
const garminActivityID = 42
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"},
m := &MockClient{
Activities: []Activity{
{ActivityID: garminActivityID, ActivityType: ActivityType{TypeKey: "running"},
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
},
Splits: map[int64]garmin.ActivitySplits{
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
Splits: map[int64]ActivitySplits{
garminActivityID: {ActivityID: garminActivityID, Laps: []Lap{
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, AverageHR: 150, AverageSpeed: 3.33, IntensityType: "ACTIVE"},
}},
},
Details: map[int64]garmin.ActivityDetails{
Details: map[int64]ActivityDetails{
garminActivityID: {
ActivityID: garminActivityID,
MetricDescriptors: []garmin.MetricDescriptor{
MetricDescriptors: []MetricDescriptor{
{Key: "directHeartRate", MetricsIndex: 0},
{Key: "sumElapsedDuration", MetricsIndex: 1},
},
},
},
}
svc := NewService(m, db, userID, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewSync(m, db, userID, SyncConfig{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if _, err := svc.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore: %v", err)
@@ -304,26 +300,26 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
const garminActivityID = 55
const workoutID = 999
workoutIDPtr := int64(workoutID)
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
m := &MockClient{
Activities: []Activity{
{ActivityID: garminActivityID, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
Splits: map[int64]ActivitySplits{
garminActivityID: {ActivityID: garminActivityID, Laps: []Lap{
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
}},
},
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
Workouts: map[int64]garmin.Workout{
workoutID: {Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
Details: map[int64]ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
Workouts: map[int64]Workout{
workoutID: {Segments: []WorkoutSegment{
{Steps: []WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
}},
}},
},
}
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if _, err := svc.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore: %v", err)
@@ -356,31 +352,31 @@ func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.
const garminActivityID = 56
const workoutID = 1000
workoutIDPtr := int64(workoutID)
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
m := &MockClient{
Activities: []Activity{
{ActivityID: garminActivityID, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
Splits: map[int64]ActivitySplits{
garminActivityID: {ActivityID: garminActivityID, Laps: []Lap{
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
{LapIndex: 2, Duration: 600, ElapsedDuration: 600, Distance: 2000, IntensityType: "REST"},
}},
},
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
Workouts: map[int64]garmin.Workout{
Details: map[int64]ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
Workouts: map[int64]Workout{
// One step for two recorded laps -- the second lap is an
// unplanned continuation past the workout's end (confirmed via
// Garmin Connect against a real activity), not a genuine
// mismatch, so the first lap should still get a real target.
workoutID: {Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
workoutID: {Segments: []WorkoutSegment{
{Steps: []WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
}},
}},
},
}
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if _, err := svc.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore: %v", err)
@@ -456,10 +452,10 @@ func TestBackfillCore_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
m := &MockClient{Activities: []Activity{
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, userID, 10)
@@ -493,10 +489,10 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
m := &MockClient{Activities: []Activity{
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, userID, 10)
@@ -536,12 +532,12 @@ func TestBackfillCore_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
m := &MockClient{Activities: []Activity{
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10}, now)
setBackfillHorizon(t, db, userID, 10)
if _, err := svc.backfillCore(ctx); err != nil {
t.Fatalf("first backfillCore: %v", err)
@@ -552,7 +548,7 @@ func TestBackfillCore_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
// watermark (not re-fetch the already-covered recent window) but still
// make progress toward the new, deeper horizon.
setBackfillHorizon(t, db, userID, 30)
svc2 := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
svc2 := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10}, now)
if _, err := svc2.backfillCore(ctx); err != nil {
t.Fatalf("second backfillCore: %v", err)
}
@@ -574,19 +570,19 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-08 06:00:00", Distance: 5000, Duration: 1500},
m := &MockClient{
Activities: []Activity{
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
{ActivityID: 2, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-08 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{
Splits: map[int64]ActivitySplits{
1: {ActivityID: 1}, 2: {ActivityID: 2},
},
Details: map[int64]garmin.ActivityDetails{
Details: map[int64]ActivityDetails{
1: {ActivityID: 1}, 2: {ActivityID: 2},
},
}
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, userID, 10)
@@ -633,7 +629,7 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
// waitForPhase polls svc.Progress() until it reports phase or timeout
// elapses, returning whether it was observed. Polling (rather than a single
// fixed-delay sample) keeps this robust against slower/loaded CI machines.
func waitForPhase(svc *Service, phase string, timeout time.Duration) bool {
func waitForPhase(svc *Sync, phase string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for {
if svc.Progress().Phase == phase {
@@ -661,17 +657,17 @@ func TestFullSync_ReportsPhaseTransitionsThroughDiscoveringToIdle(t *testing.T)
const delay = 200 * time.Millisecond
workoutID := int64(101)
m := &mock.Client{
m := &MockClient{
Delay: delay, // gives backfillCore/incrementalSyncCore's GetActivities calls real wall-clock duration
Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutID,
Activities: []Activity{
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &workoutID,
StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{1: {ActivityID: 1}},
Details: map[int64]garmin.ActivityDetails{1: {ActivityID: 1}},
Workouts: map[int64]garmin.Workout{workoutID: {}},
Splits: map[int64]ActivitySplits{1: {ActivityID: 1}},
Details: map[int64]ActivityDetails{1: {ActivityID: 1}},
Workouts: map[int64]Workout{workoutID: {}},
}
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, userID, 10)
@@ -711,24 +707,24 @@ func TestFillPendingDetails_ReportsPhaseAwareProgressThroughActivitiesAndWorkout
userID := provisionTestUser(t, db)
const n = 2
m := &mock.Client{
Activities: []garmin.Activity{},
Splits: map[int64]garmin.ActivitySplits{},
Details: map[int64]garmin.ActivityDetails{},
Workouts: map[int64]garmin.Workout{},
m := &MockClient{
Activities: []Activity{},
Splits: map[int64]ActivitySplits{},
Details: map[int64]ActivityDetails{},
Workouts: map[int64]Workout{},
}
for i := int64(1); i <= n; i++ {
workoutID := i + 100
m.Activities = append(m.Activities, garmin.Activity{
ActivityID: i, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutID,
m.Activities = append(m.Activities, Activity{
ActivityID: i, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &workoutID,
StartTimeGMT: "2026-07-0" + string(rune('0'+i)) + " 06:00:00", Distance: 5000, Duration: 1500,
})
m.Splits[i] = garmin.ActivitySplits{ActivityID: i}
m.Details[i] = garmin.ActivityDetails{ActivityID: i}
m.Workouts[workoutID] = garmin.Workout{}
m.Splits[i] = ActivitySplits{ActivityID: i}
m.Details[i] = ActivityDetails{ActivityID: i}
m.Workouts[workoutID] = Workout{}
}
svc := NewService(m, db, userID, Config{InterCallDelay: 150 * time.Millisecond},
svc := NewSync(m, db, userID, SyncConfig{InterCallDelay: 150 * time.Millisecond},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if _, err := svc.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore: %v", err)
@@ -806,16 +802,16 @@ func TestFillPendingDetails_RetriesWorkoutFetchForActivityWithDetailsAlreadyFetc
t.Fatalf("ReplaceLaps: %v", err)
}
m := &mock.Client{
Workouts: map[int64]garmin.Workout{
workoutID: {Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
m := &MockClient{
Workouts: map[int64]Workout{
workoutID: {Segments: []WorkoutSegment{
{Steps: []WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
}},
}},
},
}
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
// A prior FillPendingDetails call (which had ActivitiesMissingDetails
// return nothing, since details are already fetched) still reaches this
@@ -841,22 +837,22 @@ func TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass(t
const failingWorkoutID = 200
const okWorkoutID = 201
failingPtr, okPtr := int64(failingWorkoutID), int64(okWorkoutID)
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &failingPtr,
m := &MockClient{
Activities: []Activity{
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &failingPtr,
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &okPtr,
{ActivityID: 2, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &okPtr,
StartTimeGMT: "2026-07-02 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{
1: {ActivityID: 1, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
2: {ActivityID: 2, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
Splits: map[int64]ActivitySplits{
1: {ActivityID: 1, Laps: []Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
2: {ActivityID: 2, Laps: []Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
},
Details: map[int64]garmin.ActivityDetails{1: {ActivityID: 1}, 2: {ActivityID: 2}},
Workouts: map[int64]garmin.Workout{
okWorkoutID: {Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
Details: map[int64]ActivityDetails{1: {ActivityID: 1}, 2: {ActivityID: 2}},
Workouts: map[int64]Workout{
okWorkoutID: {Segments: []WorkoutSegment{
{Steps: []WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
}},
}},
},
@@ -864,8 +860,8 @@ func TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass(t
}
// Not timing-sensitive -- keep InterCallDelay negligible so the 2
// activities/2 workouts here don't cost real wall-clock seconds
// (Config{}'s default is 1s per gap).
svc := NewService(m, db, userID, Config{InterCallDelay: time.Millisecond},
// (SyncConfig{}'s default is 1s per gap).
svc := NewSync(m, db, userID, SyncConfig{InterCallDelay: time.Millisecond},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if _, err := svc.backfillCore(ctx); err != nil {
@@ -909,19 +905,19 @@ func TestFillPendingDetails_NotFoundWorkoutStopsBeingRetried(t *testing.T) {
const missingWorkoutID = 300
missingPtr := int64(missingWorkoutID)
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &missingPtr,
m := &MockClient{
Activities: []Activity{
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &missingPtr,
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{
1: {ActivityID: 1, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
Splits: map[int64]ActivitySplits{
1: {ActivityID: 1, Laps: []Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
},
Details: map[int64]garmin.ActivityDetails{1: {ActivityID: 1}},
Workouts: map[int64]garmin.Workout{},
WorkoutErrByID: map[int64]error{missingWorkoutID: fmt.Errorf("API Error 404: %w", garmin.ErrNotFound)},
Details: map[int64]ActivityDetails{1: {ActivityID: 1}},
Workouts: map[int64]Workout{},
WorkoutErrByID: map[int64]error{missingWorkoutID: fmt.Errorf("API Error 404: %w", ErrNotFound)},
}
svc := NewService(m, db, userID, Config{InterCallDelay: time.Millisecond},
svc := NewSync(m, db, userID, SyncConfig{InterCallDelay: time.Millisecond},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if _, err := svc.backfillCore(ctx); err != nil {
@@ -976,14 +972,14 @@ func TestService_TwoUsersSyncIndependently(t *testing.T) {
t.Fatalf("ProvisionUser(b): %v", err)
}
mA := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
mA := &MockClient{Activities: []Activity{
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
}}
mB := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 8000, Duration: 2400},
mB := &MockClient{Activities: []Activity{
{ActivityID: 2, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 8000, Duration: 2400},
}}
svcA := NewService(mA, db, userA, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svcB := NewService(mB, db, userB, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svcA := NewSync(mA, db, userA, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svcB := NewSync(mB, db, userB, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if _, err := svcA.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore(a): %v", err)

View File

@@ -7,14 +7,21 @@ writing-plans flow (see `docs/superpowers/specs/` and `docs/superpowers/plans/`
for that history) and remove it from here once a spec exists.
## Backlog
- quickfixes
- make setupSessionIdleTimeout an application configuration (key session.idle_timeout), check difference with appCfg.SessionDuration
- don't put id_token in Claim, store it in the cookie apart from Claim
- find alternative to deprecated React.FormEvent
- replace all go log calls by our application logger
- new workout kinds
- add "Recovery", "Quick", and "Sprint" workout kinds
- order to follow (in activities page filter buttons, in analysis page combobox, in profile page workout kinds cards) is Recovery -> Easy -> Long -> Tempo -> 60' Threshold -> 30' Threshold -> Quick -> Intervals -> MAS Test -> Sprint -> Race
- adapt the color palette from blue to purple according to this order (starting with blue for recovery -> green -> yellow -> orange -> red until purple for race), put the color code in DB (UI shall not resolve it with kindColor based on workout kind name)
- workout templates
- provide workout templates in YAML (see docs/workouts.yaml for the structure)
- first time setup page
- once connection is validated, add a step to ask user its best race results on 5k, 10k, half-marathon and marathon (at least one shall be provided) and store it in the profile. these values will be updated later during synchronization (as we can detect race kinds with their distance)
- add another step to enter latest known MAS (in mm:ss/km) and store it in the profile. this value will be updated later during synchronization (as we can assign an activity with "MAS test" kind)
- profile page
- profile page
- move "Heart rate" card before "Activity analysis" card and rename it to "Characteristics"
- add genre (male/female) and age in "Characteristics"
- rename "Activity analysis" card to "Activities"
@@ -63,8 +70,9 @@ for that history) and remove it from here once a spec exists.
- discovery of warm-up, cool-down phases, so we can read activity per blocks (with detection of extension of a block compared to linked workout)
- discovery of additional blocks done but not requested by linked workout (for example, additional small sprints at the end of an easy run)
- add icon to indicate if an activity has an associated workout
-
- list layout with smaller graphs (no scale, no targets, no , detailed view with different graphs for each phase incl. delta with workout)
- manage 2 layouts
- list layout with smaller graphs (no scale, no targets)
- detailed layout (once clicked on details...) showing graphs for each block incl. delta with workout)
## Someday / maybe

View File

@@ -1256,8 +1256,8 @@ git commit -m "feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to
### Task 7: Classification reads max HR from the profile, not static config
**Files:**
- Modify: `backend/internal/sync/service.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310)
- Modify: `backend/internal/sync/service_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`)
- Modify: `../../../backend/internal/garmin/sync.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310)
- Modify: `../../../backend/internal/garmin/sync_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`)
**Interfaces:**
- Consumes: `store.Profile.MaxHeartRate` (Task 1), `db.GetProfile` (Task 1).
@@ -1265,7 +1265,7 @@ git commit -m "feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to
- [ ] **Step 1: Update the test first**
In `backend/internal/sync/service_test.go`, find this line inside `TestFillPendingDetailsAndClassify_EndToEnd` (currently constructs the service with `MaxHR: 190`):
In `../../../backend/internal/garmin/sync_test.go`, find this line inside `TestFillPendingDetailsAndClassify_EndToEnd` (currently constructs the service with `MaxHR: 190`):
```go
svc := NewService(m, db, Config{MinConfidence: 0.5, MaxHR: 190}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
@@ -1284,7 +1284,7 @@ Expected: passes as-is right now (we haven't removed the field yet) — this ste
- [ ] **Step 3: Remove `MaxHR` from `Config` and source it from the profile in `ClassifyActivity`**
In `backend/internal/sync/service.go`, the `Config` struct currently ends with (lines 35-39):
In `../../../backend/internal/garmin/sync.go`, the `Config` struct currently ends with (lines 35-39):
```go
// MinConfidence is the classify.Classify threshold below which even a
@@ -1381,7 +1381,7 @@ Expected: all PASS. If `cmd/geniusrund/main.go` fails to build because it still
- [ ] **Step 6: Commit**
```bash
cd backend && git add internal/sync/service.go internal/sync/service_test.go
cd backend && git add internal/sync/sync.go internal/sync/sync_test.go
git commit -m "feat: classification reads max HR from the profile instead of static config"
```
@@ -1390,7 +1390,7 @@ git commit -m "feat: classification reads max HR from the profile instead of sta
### Task 8: `internal/config` and `cmd/geniusrund` — credentials come from the profile
**Files:**
- Modify: `backend/internal/config/config.go` (full rewrite — small file, shown complete below)
- Modify: `../../../backend/internal/config/envconfig.go` (full rewrite — small file, shown complete below)
- Modify: `backend/cmd/geniusrund/main.go` (lines 21-46)
**Interfaces:**
@@ -1399,7 +1399,7 @@ git commit -m "feat: classification reads max HR from the profile instead of sta
- [ ] **Step 1: Rewrite `internal/config/config.go`**
Replace the full contents of `backend/internal/config/config.go` with:
Replace the full contents of `../../../backend/internal/config/envconfig.go` with:
```go
// Package config loads geniusrund's runtime infrastructure configuration
@@ -1590,7 +1590,7 @@ Expected: the server starts (no "GARMIN_EMAIL required" error), `/api/profile` r
- [ ] **Step 5: Commit**
```bash
cd backend && git add internal/config/config.go cmd/geniusrund/main.go
cd backend && git add internal/config/envconfig.go cmd/geniusrund/main.go
git commit -m "feat: source Garmin credentials from the profile instead of env vars"
```

View File

@@ -61,15 +61,15 @@ CLAUDE.md [MODIFY] document the new env vars / auth architectur
### Task 1: Config — OIDC/session settings
**Files:**
- Modify: `backend/internal/config/config.go`
- Create: `backend/internal/config/config_test.go`
- Modify: `../../../backend/internal/config/envconfig.go`
- Create: `../../../backend/internal/config/envconfig_test.go`
**Interfaces:**
- Produces: `Config.OIDCIssuerURL/OIDCClientID/OIDCClientSecret/OIDCRedirectURL/OIDCRequiredRole string`, `Config.PublicBaseURL string`, `Config.SessionSecret []byte`, `Config.SessionDuration time.Duration`, `Config.SessionSecure bool` — consumed by Task 6 (`main.go`) to build `auth.OIDCConfig` and `api.SessionConfig`.
- [ ] **Step 1: Write the failing tests**
Create `backend/internal/config/config_test.go`:
Create `../../../backend/internal/config/envconfig_test.go`:
```go
package config
@@ -179,7 +179,7 @@ Expected: compile errors (`cfg.OIDCRedirectURL` etc. undefined) — that's the e
- [ ] **Step 3: Implement the config fields and validation**
In `backend/internal/config/config.go`, add `"strings"` to the imports, add these fields to `Config` (after `MinConfidence`/`IncrementalSyncEvery`):
In `../../../backend/internal/config/envconfig.go`, add `"strings"` to the imports, add these fields to `Config` (after `MinConfidence`/`IncrementalSyncEvery`):
```go
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
@@ -256,7 +256,7 @@ Expected: PASS (all `TestLoad_*` cases).
- [ ] **Step 5: Commit**
```bash
git add backend/internal/config/config.go backend/internal/config/config_test.go
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
git commit -m "config: add OIDC/session env vars for the login gate"
```

View File

@@ -17,25 +17,25 @@
- `GARMIN_WRAPPER_PYTHON` is optional, defaulting to `"python3"` (resolved via `PATH`) — `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` are retired entirely, not renamed.
- `garminconnect`'s actual method kwargs must be used exactly: `get_activities_by_date(startdate, enddate)`, `get_activity_splits(activity_id)`, `get_activity_details(activity_id)`, `get_workout_by_id(workout_id)` (confirmed via `inspect.signature` against the installed package — note `startdate`/`enddate`, not `start_date`/`end_date`).
- `gofmt -l .` must report nothing; `go build ./...`, `go vet ./...`, and `go test ./...` must all pass before the final commit.
- `pytest` must pass in `backend/internal/garmin/pyscript/`.
- `pytest` must pass in `../../../backend/internal/garmin/wrapper/`.
---
### Task 1: Python wrapper (`wrapper.py`) with its own test suite
**Files:**
- Create: `backend/internal/garmin/pyscript/wrapper.py`
- Create: `backend/internal/garmin/pyscript/pyproject.toml`
- Create: `backend/internal/garmin/pyscript/.gitignore`
- Test: `backend/internal/garmin/pyscript/tests/__init__.py`
- Test: `backend/internal/garmin/pyscript/tests/test_wrapper.py`
- Create: `../../../backend/internal/garmin/wrapper/wrapper.py`
- Create: `../../../backend/internal/garmin/wrapper/pyproject.toml`
- Create: `../../../backend/internal/garmin/wrapper/.gitignore`
- Test: `../../../backend/internal/garmin/wrapper/tests/__init__.py`
- Test: `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`
**Interfaces:**
- Produces: `wrapper.dispatch(req: dict) -> dict` — the single entry point Go's subprocess talks to indirectly via stdin/stdout; also `wrapper._auth_state`, `wrapper._client`, `wrapper._mfa_input_queue`, `wrapper._login_result_queue`, `wrapper.Garmin` (re-exported name, patchable in tests), used only within this task's own test suite.
- [ ] **Step 1: Create the Python project manifest**
`backend/internal/garmin/pyscript/pyproject.toml`:
`../../../backend/internal/garmin/wrapper/pyproject.toml`:
```toml
[project]
@@ -55,7 +55,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
```
`backend/internal/garmin/pyscript/.gitignore`:
`../../../backend/internal/garmin/wrapper/.gitignore`:
```
.venv/
@@ -68,7 +68,7 @@ __pycache__/
Run:
```bash
cd backend/internal/garmin/pyscript
cd backend/internal/garmin/wrapper
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
```
@@ -76,9 +76,9 @@ Expected: installs `garminconnect` and `pytest` into `.venv` with no errors.
- [ ] **Step 3: Write the failing test suite**
`backend/internal/garmin/pyscript/tests/__init__.py`: empty file.
`../../../backend/internal/garmin/wrapper/tests/__init__.py`: empty file.
`backend/internal/garmin/pyscript/tests/test_wrapper.py`:
`../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`:
```python
import os
@@ -216,7 +216,7 @@ Expected: `ModuleNotFoundError: No module named 'wrapper'` (or collection failur
- [ ] **Step 5: Write `wrapper.py`**
`backend/internal/garmin/pyscript/wrapper.py`:
`../../../backend/internal/garmin/wrapper/wrapper.py`:
```python
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited
@@ -393,7 +393,7 @@ Expected: all tests PASS.
- [ ] **Step 7: Commit**
```bash
git add backend/internal/garmin/pyscript
git add backend/internal/garmin/wrapper
git commit -m "$(cat <<'EOF'
feat(garmin): add direct garminconnect wrapper script
@@ -584,7 +584,7 @@ Expected: compile failure — `subprocessClient`, `wireResponse`, `roundTrip`, `
```go
// Package garmin wraps a direct garminconnect subprocess (see
// pyscript/wrapper.py) as a narrow Go client interface, so the rest of
// wrapper/wrapper.py) as a narrow Go client interface, so the rest of
// geniusrun never deals with the wire protocol directly.
package garmin
@@ -861,7 +861,7 @@ git add backend/internal/garmin/client.go backend/internal/garmin/client_test.go
git commit -m "$(cat <<'EOF'
feat(garmin): replace mcp-go transport with a JSON-lines subprocess client
subprocessClient spawns the embedded pyscript/wrapper.py over os/exec and
subprocessClient spawns the embedded wrapper/wrapper.py over os/exec and
speaks newline-delimited JSON instead of MCP. Auth/data methods land in
follow-up commits; this is the transport + lifecycle plumbing only.
@@ -1370,8 +1370,8 @@ EOF
### Task 5: Update `internal/config` (drop server-path env var, default the python path)
**Files:**
- Modify: `backend/internal/config/config.go`
- Modify: `backend/internal/config/config_test.go`
- Modify: `../../../backend/internal/config/envconfig.go`
- Modify: `../../../backend/internal/config/envconfig_test.go`
**Interfaces:**
- Consumes: nothing from Tasks 14.
@@ -1379,7 +1379,7 @@ EOF
- [ ] **Step 1: Write the failing tests**
In `backend/internal/config/config_test.go`, update `setRequiredEnv` (remove the two now-optional lines):
In `../../../backend/internal/config/envconfig_test.go`, update `setRequiredEnv` (remove the two now-optional lines):
```go
func setRequiredEnv(t *testing.T) {
@@ -1425,11 +1425,11 @@ func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cd backend && go test ./internal/config/... -v`
Expected: `TestLoad_GarminPythonPathDefaultsToPython3` fails (`GarminPythonPath` is currently `""`, and `Load()` currently errors out entirely since `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` are no longer set by the trimmed `setRequiredEnv`) — every other test in the package should also now fail with "MCP_GARMIN_PYTHON is required", confirming the removed env vars were the only thing missing.
Expected: `TestLoad_GarminPythonPathDefaultsToPython3` fails (`PythonPath` is currently `""`, and `Load()` currently errors out entirely since `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` are no longer set by the trimmed `setRequiredEnv`) — every other test in the package should also now fail with "MCP_GARMIN_PYTHON is required", confirming the removed env vars were the only thing missing.
- [ ] **Step 3: Update `config.go`**
- [ ] **Step 3: Update `envconfig.go`**
In `backend/internal/config/config.go`, replace the two Garmin subprocess-path fields:
In `../../../backend/internal/config/envconfig.go`, replace the two Garmin subprocess-path fields:
```go
// GarminPythonPath is mcp-garmin's venv python executable.
@@ -1479,7 +1479,7 @@ Expected: all tests PASS.
- [ ] **Step 5: Commit**
```bash
git add backend/internal/config/config.go backend/internal/config/config_test.go
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
git commit -m "$(cat <<'EOF'
feat(config): drop MCP_GARMIN_SERVER, default GARMIN_WRAPPER_PYTHON
@@ -1528,7 +1528,7 @@ to:
}, appsync.Config{
```
- [ ] **Step 2: Update the `GarminBase` doc comment in `server.go`**
- [ ] **Step 2: Update the `ClientConfig` doc comment in `server.go`**
In `backend/internal/api/server.go`, change:
@@ -1749,4 +1749,4 @@ EOF
## Follow-up note (not a task in this plan)
The standalone `mcp-garmin` repo (`/Users/kriss/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin`) is superseded by `backend/internal/garmin/pyscript/` and can be archived once this plan lands — that's a separate-repo action for you to do directly (e.g. marking it archived on your SCM host), not something this plan's tasks touch.
The standalone `mcp-garmin` repo (`/Users/kriss/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin`) is superseded by `../../../backend/internal/garmin/wrapper/` and can be archived once this plan lands — that's a separate-repo action for you to do directly (e.g. marking it archived on your SCM host), not something this plan's tasks touch.

View File

@@ -20,15 +20,15 @@
### Task 1: Add `FrontendURL` to `internal/config`
**Files:**
- Modify: `backend/internal/config/config.go`
- Modify: `backend/internal/config/config_test.go`
- Modify: `../../../backend/internal/config/envconfig.go`
- Modify: `../../../backend/internal/config/envconfig_test.go`
**Interfaces:**
- Produces: `Config.FrontendURL string` — set by `Load()`, defaults to `Config.PublicBaseURL` when `GENIUSRUN_FRONTEND_URL` is unset, trimmed of any trailing slash otherwise. Consumed by Task 2's `main.go` wiring.
- [ ] **Step 1: Write the failing tests**
Add to `backend/internal/config/config_test.go` (anywhere in the file, e.g. after `TestLoad_GarminPythonPathExplicitOverridesDefault`):
Add to `../../../backend/internal/config/envconfig_test.go` (anywhere in the file, e.g. after `TestLoad_GarminPythonPathExplicitOverridesDefault`):
```go
func TestLoad_FrontendURLDefaultsToPublicBaseURL(t *testing.T) {
@@ -65,7 +65,7 @@ Expected: compile failure — `Config.FrontendURL` doesn't exist yet.
- [ ] **Step 3: Add the field and default logic**
In `backend/internal/config/config.go`, add a new field right after `PublicBaseURL` in the `Config` struct:
In `../../../backend/internal/config/envconfig.go`, add a new field right after `PublicBaseURL` in the `Config` struct:
```go
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
@@ -104,7 +104,7 @@ Expected: all tests PASS, including the two new ones.
- [ ] **Step 5: Commit**
```bash
git add backend/internal/config/config.go backend/internal/config/config_test.go
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
git commit -m "$(cat <<'EOF'
feat(config): add GENIUSRUN_FRONTEND_URL, defaulting to PublicBaseURL

View File

@@ -2223,14 +2223,14 @@ EOF
## Task 10: `internal/sync.Service` becomes per-user
**Files:**
- Modify: `backend/internal/sync/service.go`
- Modify: `backend/internal/sync/service_test.go`
- Modify: `../../../backend/internal/garmin/sync.go`
- Modify: `../../../backend/internal/garmin/sync_test.go`
**Interfaces:**
- Consumes: every scoped store method from Tasks 4-8.
- Produces: `func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service`. Every other `Service` method (`Backfill`, `IncrementalSync`, `FullSync`, `ResetAll`, `FillPendingDetails`, `ClassifyActivity`, `Progress`) keeps its exact current signature — the userID is baked into the `Service` instance at construction (one `Service` per logged-in user, per the design), so callers in `internal/api` (Tasks 13/15) never pass a userID to these methods directly, only to `NewService` itself.
- [ ] **Step 1: Add the `userID` field and thread it through every store call in `backend/internal/sync/service.go`**
- [ ] **Step 1: Add the `userID` field and thread it through every store call in `../../../backend/internal/garmin/sync.go`**
Change the `Service` struct and `NewService`:
@@ -2269,7 +2269,7 @@ Then, in every remaining method, prefix `s.userID` as the new argument to every
- `fillActivityDetails`: `s.db.SetActivityWorkout(ctx, a.ID, ...)``s.db.SetActivityWorkout(ctx, s.userID, a.ID, ...)`; `s.db.ReplaceActivitySamples(ctx, a.ID, ...)``s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, ...)`; `s.db.ReplaceLaps(ctx, a.ID, ...)``s.db.ReplaceLaps(ctx, s.userID, a.ID, ...)`; `s.db.SetActivityDetails(ctx, a.ID, ...)``s.db.SetActivityDetails(ctx, s.userID, a.ID, ...)`; `s.db.SetActivitySplitsFetched(ctx, a.ID)``s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)`.
- `ClassifyActivity`: `s.db.GetActivity(ctx, activityID)``s.db.GetActivity(ctx, s.userID, activityID)`; `s.db.LapsForActivity(ctx, activityID)``s.db.LapsForActivity(ctx, s.userID, activityID)`; `s.db.ListWorkoutKinds(ctx, true)``s.db.ListWorkoutKinds(ctx, s.userID, true)`; `s.db.GetProfile(ctx)``s.db.GetProfile(ctx, s.userID)`; `s.db.InsertKindAssignment(ctx, store.KindAssignment{...})``s.db.InsertKindAssignment(ctx, s.userID, store.KindAssignment{...})`.
- [ ] **Step 2: Fix `backend/internal/sync/service_test.go`'s `NewService` calls**
- [ ] **Step 2: Fix `../../../backend/internal/garmin/sync_test.go`'s `NewService` calls**
Every `NewService(m, db, Config{...}, fixedNow(...))` call in this file needs a `userID` inserted as the third argument. Add a shared helper near the top of the file (next to `openTestDB`/`fixedNow`):
@@ -2355,7 +2355,7 @@ Run: `cd backend && gofmt -l internal/sync/`
Expected: no output.
```bash
git add backend/internal/sync/service.go backend/internal/sync/service_test.go
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go
git commit -m "$(cat <<'EOF'
sync: scope Service to one user per instance
@@ -2372,12 +2372,12 @@ EOF
## Task 11: `internal/config` additions
**Files:**
- Modify: `backend/internal/config/config.go`
- Modify: `../../../backend/internal/config/envconfig.go`
**Interfaces:**
- Produces: `Config.GarminTokenStoreRoot` (renamed from `GarminTokenStore`, same env var `GARMIN_TOKENSTORE`, now holds a root directory rather than a single path); `Config.LegacyOwnerOIDCSub` (new, optional, env var `GENIUSRUN_LEGACY_OWNER_OIDC_SUB`). Task 13's `main.go` wiring consumes both.
- [ ] **Step 1: Rename `GarminTokenStore` → `GarminTokenStoreRoot` and add `LegacyOwnerOIDCSub`**
- [ ] **Step 1: Rename `GarminTokenStore` → `TokenStoreRoot` and add `LegacyOwnerOIDCSub`**
In the `Config` struct, change:
@@ -2427,7 +2427,7 @@ Run: `cd backend && gofmt -l internal/config/`
Expected: no output.
```bash
git add backend/internal/config/config.go
git add backend/internal/config/envconfig.go
git commit -m "$(cat <<'EOF'
config: add per-user Garmin token store root and legacy-owner bootstrap var
@@ -2442,17 +2442,17 @@ EOF
## Task 12: User-resolution middleware + `POST /api/setup`
**Files:**
- Create: `backend/internal/api/usercontext.go`
- Create: `../../../backend/internal/api/user.go`
- Create: `backend/internal/api/setup.go`
- Modify: `backend/internal/api/session.go` (extend `sessionMeResponse`, `handleSessionMe`)
- Create: `backend/internal/api/usercontext_test.go`
- Create: `../../../backend/internal/api/user_test.go`
- Create: `backend/internal/api/setup_test.go`
**Interfaces:**
- Consumes: `store.GetUserBySub`/`store.ProvisionUser` (Task 2), `auth.ClaimsFromContext` (existing).
- Produces: `func (s *Server) resolveUser(next http.Handler) http.Handler` (middleware); `func requireProvisionedUser(next http.Handler) http.Handler` (middleware); `func userFromContext(ctx context.Context) (resolvedUser, bool)`; `func userIDFromContext(ctx context.Context) int64`; `func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request)`. Task 13 wires all of these into the router; every handler task (14/15) calls `userIDFromContext`.
- [ ] **Step 1: Write the failing tests in `backend/internal/api/usercontext_test.go`**
- [ ] **Step 1: Write the failing tests in `../../../backend/internal/api/user_test.go`**
Since `auth`'s session context key is unexported, this test drives the real chain (`auth.RequireSession` then `s.resolveUser`) via a minted session cookie — the same `doJSON` helper `api_test.go` already uses for every other handler test — rather than trying to poke the context directly.
@@ -2521,7 +2521,7 @@ func TestRequireProvisionedUser_RejectsWhenNoUserResolved(t *testing.T) {
Run: `cd backend && go vet ./internal/api/...`
Expected: FAIL — `s.resolveUser`/`userFromContext`/`requireProvisionedUser` undefined.
- [ ] **Step 3: Write `backend/internal/api/usercontext.go`**
- [ ] **Step 3: Write `../../../backend/internal/api/user.go`**
```go
package api
@@ -2790,7 +2790,7 @@ Run: `cd backend && gofmt -l internal/api/`
Expected: no output.
```bash
git add backend/internal/api/usercontext.go backend/internal/api/usercontext_test.go backend/internal/api/setup.go backend/internal/api/setup_test.go backend/internal/api/session.go backend/internal/api/server.go
git add backend/internal/api/user.go backend/internal/api/user_test.go backend/internal/api/setup.go backend/internal/api/setup_test.go backend/internal/api/session.go backend/internal/api/server.go
git commit -m "$(cat <<'EOF'
api: add user-resolution middleware and POST /api/setup
@@ -3302,7 +3302,7 @@ func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
- [ ] **Step 4: Build everything and fix remaining call sites**
Run: `cd backend && go build ./... 2>&1 | head -50`
Expected: remaining errors are all inside `internal/api/*.go` handler files (`profile.go`, `activities.go`, `sync.go`, `kinds.go`, `review.go`, `reclassify.go`, `progression.go`, `auth.go`) and `cmd/seedsample/main.go` — referencing `s.Garmin`/`s.Sync` (now removed) or store methods missing `userID`. These are fixed in Tasks 14/15/17; confirm no errors remain in `server.go`, `main.go`, or `api_test.go` itself.
Expected: remaining errors are all inside `internal/api/*.go` handler files (`profile.go`, `activities.go`, `sync.go`, `kinds.go`, `review.go`, `reclassify.go`, `progression.go`, `garmin.go`) and `cmd/seedsample/main.go` — referencing `s.Garmin`/`s.Sync` (now removed) or store methods missing `userID`. These are fixed in Tasks 14/15/17; confirm no errors remain in `server.go`, `main.go`, or `api_test.go` itself.
- [ ] **Step 5: `gofmt` and commit**
@@ -3325,12 +3325,12 @@ EOF
---
## Task 14: Thread `userID` into `profile.go`, `kinds.go`, `auth.go`, `progression.go`
## Task 14: Thread `userID` into `profile.go`, `kinds.go`, `garmin.go`, `progression.go`
**Files:**
- Modify: `backend/internal/api/profile.go`
- Modify: `backend/internal/api/kinds.go`
- Modify: `backend/internal/api/auth.go`
- Modify: `../../../backend/internal/api/garmin.go`
- Modify: `backend/internal/api/progression.go`
- Modify: `backend/internal/api/api_test.go` (no new call-site changes expected beyond what Task 13 already made — this task only touches handler bodies, not test bodies, since the HTTP-level test assertions are unchanged)
@@ -3508,7 +3508,7 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
}
```
- [ ] **Step 3: Update `backend/internal/api/auth.go`**
- [ ] **Step 3: Update `../../../backend/internal/api/garmin.go`**
Keep `authResponse`, `authStatusString` unchanged; replace `recordAuthResult` and the three handlers:
@@ -3636,7 +3636,7 @@ Run: `cd backend && gofmt -l internal/api/`
Expected: no output.
```bash
git add backend/internal/api/profile.go backend/internal/api/kinds.go backend/internal/api/auth.go backend/internal/api/progression.go
git add backend/internal/api/profile.go backend/internal/api/kinds.go backend/internal/api/garmin.go backend/internal/api/progression.go
git commit -m "$(cat <<'EOF'
api: scope profile, workout-kind, Garmin auth, and progression handlers to userID

View File

@@ -4,7 +4,7 @@
**Goal:** Make connecting a Garmin account a mandatory, persisted gate during onboarding (and on every subsequent login until it succeeds), instead of an optional step left to the Profile page.
**Architecture:** Add a nullable `garmin_connected_at` timestamp to `profile`, set once on the first successful Garmin authentication (`handleAuthLogin`/`handleAuthMFA`). Expose it via `GET /api/session/me`. `LoginGate.tsx` becomes a three-way fork (`!has_profile``CreateProfile`, `has_profile && !garmin_connected` → new `ConnectGarmin`, else → `App`), so a user who abandons the connect step re-enters at `ConnectGarmin` on their next login rather than skipping straight into the app.
**Architecture:** Add a nullable `garmin_connected_at` timestamp to `profile`, set once on the first successful Garmin authentication (`handleGarminAuthLogin`/`handleGarminAuthMFA`). Expose it via `GET /api/session/me`. `LoginGate.tsx` becomes a three-way fork (`!has_profile``CreateProfile`, `has_profile && !garmin_connected` → new `ConnectGarmin`, else → `App`), so a user who abandons the connect step re-enters at `ConnectGarmin` on their next login rather than skipping straight into the app.
**Tech Stack:** Go (`database/sql` via `modernc.org/sqlite`, chi router), React + TypeScript (Vite), no frontend test framework.
@@ -246,7 +246,7 @@ git commit -m "feat(store): persist garmin_connected_at, set once on first succe
### Task 2: Wire `MarkGarminConnected` into the auth handlers and expose it via session/me
**Files:**
- Modify: `backend/internal/api/auth.go` (`recordAuthResult`, both call sites)
- Modify: `../../../backend/internal/api/garmin.go` (`recordAuthResult`, both call sites)
- Modify: `backend/internal/api/session.go` (`sessionMeResponse`, `handleSessionMe`)
- Modify: `backend/internal/api/api_test.go` (new tests)
- Modify: `backend/internal/api/isolation_test.go` (new adversarial test)
@@ -342,7 +342,7 @@ func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
Run: `cd backend && go test ./internal/api/... -run 'TestSessionMe_ReportsGarminConnected|TestSessionMe_GarminConnectedStaysFalse|TestIsolation_GarminConnected' -v`
Expected: FAIL — `sessionMeResponse` has no field `GarminConnected`.
- [ ] **Step 3: Update `recordAuthResult` in `auth.go`**
- [ ] **Step 3: Update `recordAuthResult` in `garmin.go`**
Change the imports:
@@ -379,8 +379,8 @@ func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.
}
```
In `handleAuthLogin`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
In `handleAuthMFA`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
In `handleGarminAuthLogin`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
In `handleGarminAuthMFA`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
- [ ] **Step 4: Update `sessionMeResponse` and `handleSessionMe` in `session.go`**
@@ -446,7 +446,7 @@ Expected: `gofmt -l .` empty; everything else passes.
- [ ] **Step 7: Commit**
```bash
git add backend/internal/api/auth.go backend/internal/api/session.go backend/internal/api/api_test.go backend/internal/api/isolation_test.go
git add backend/internal/api/garmin.go backend/internal/api/session.go backend/internal/api/api_test.go backend/internal/api/isolation_test.go
git commit -m "feat(api): persist and expose garmin_connected on successful auth"
```

View File

@@ -24,7 +24,7 @@ frontend `SyncModal` component polls it and replaces all inline sync UI in `Garm
- Frontend: `npm run build` (`tsc -b && vite build`) and `npm run lint` (oxlint) must be clean
before any commit. No frontend test suite exists -- verify new UI manually in a browser against
`cmd/seedsample` data.
- Reset all (`ResetAll`/`handleSyncReset`) is explicitly out of scope -- do not touch it beyond
- Reset all (`ResetAll`/`handleGarminSyncReset`) is explicitly out of scope -- do not touch it beyond
what's incidentally required (none is required).
- Follow this repo's existing JSON convention exactly: hand-built response maps use snake_case
keys (`in_progress`, `activities_pending_details`), but a Go struct serialized directly (like
@@ -40,7 +40,7 @@ frontend `SyncModal` component polls it and replaces all inline sync UI in `Garm
- Modify: `backend/internal/sync/service.go:99-123` (delete `Backfill`), `:194-209` (delete
`IncrementalSync`), `:224-232` (fix `FullSync`'s doc comment)
- Modify: `backend/internal/store/syncruns.go:9-11` (delete `SyncKindBackfill`/`SyncKindIncremental`)
- Modify: `backend/internal/sync/service_test.go` (rename/rewrite tests listed below)
- Modify: `../../../backend/internal/garmin/sync_test.go` (rename/rewrite tests listed below)
**Interfaces:**
- Consumes: nothing new.
@@ -58,7 +58,7 @@ check, not new information).
- [ ] **Step 2: Delete `Backfill` and `IncrementalSync`, fix `FullSync`'s doc comment**
In `backend/internal/sync/service.go`, delete this entire method (lines 99-123):
In `../../../backend/internal/garmin/sync.go`, delete this entire method (lines 99-123):
```go
// Backfill pages backward in Config.BackfillWindowDays windows until
@@ -187,7 +187,7 @@ const (
)
```
- [ ] **Step 4: Update `backend/internal/sync/service_test.go`'s callers**
- [ ] **Step 4: Update `../../../backend/internal/garmin/sync_test.go`'s callers**
Rename and rewrite (drop the redundant SyncRun assertion -- `TestFullSync_RecordsOneCombinedSyncRun`
already covers SyncRun recording thoroughly):
@@ -261,7 +261,7 @@ Expected: all packages pass, `gofmt -l .` prints nothing.
- [ ] **Step 6: Commit**
```bash
git add backend/internal/sync/service.go backend/internal/sync/service_test.go backend/internal/store/syncruns.go
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go backend/internal/store/syncruns.go
git commit -m "$(cat <<'EOF'
refactor(sync): remove dead Backfill/IncrementalSync exported wrappers
@@ -475,7 +475,7 @@ EOF
`fillActivityWorkout`)
- Modify: `backend/internal/sync/mapping.go:113-125` (`alignWorkoutTargets` signature)
- Modify: `backend/internal/garmin/mock/mock.go` (add `WorkoutErrByID`)
- Modify: `backend/internal/sync/service_test.go` (update `alignWorkoutTargets` call sites,
- Modify: `../../../backend/internal/garmin/sync_test.go` (update `alignWorkoutTargets` call sites,
rewrite `TestFillPendingDetails_ReportsLiveProgress`, add new tests)
**Interfaces:**
@@ -490,7 +490,7 @@ EOF
`alignWorkoutTargets` only ever uses `len(laps)`, never any lap's actual content -- change its
signature to take the count directly, since Task 3's `fillActivityWorkout` (added in Step 4)
needs to call it without holding a `[]garmin.Lap` (it only has `[]store.Lap` read back from the
DB). In `backend/internal/sync/service_test.go`, update all three call sites:
DB). In `../../../backend/internal/garmin/sync_test.go`, update all three call sites:
```go
targets := alignWorkoutTargets(len(laps), workout)
@@ -509,7 +509,7 @@ a `[]garmin.Lap`, not an `int` -- `len(laps)` is an `int`, mismatched argument t
- [ ] **Step 3: Change `alignWorkoutTargets`'s signature**
In `backend/internal/sync/mapping.go`, replace:
In `../../../backend/internal/garmin/mapping.go`, replace:
```go
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep {
@@ -553,7 +553,7 @@ one-extra-trailing-lap case stays accurate as-is.)
- [ ] **Step 4: Run to verify it passes**
Run: `cd backend && go test ./internal/sync/... -run TestAlignWorkoutTargets -v`
Expected: PASS (note: `fillActivityDetails` in `service.go` still calls
Expected: PASS (note: `fillActivityDetails` in `sync.go` still calls
`alignWorkoutTargets(splits.Laps, workout)` at this point -- that call site is rewritten in Step 6
below, so the package won't fully build until then; run this test with `-run
TestAlignWorkoutTargets` specifically, or expect a build failure from the not-yet-updated call
@@ -561,7 +561,7 @@ site if running the whole package).
- [ ] **Step 5: Add phase-aware `Progress` and update `FullSync`**
In `backend/internal/sync/service.go`, replace:
In `../../../backend/internal/garmin/sync.go`, replace:
```go
// Progress reports how far a currently-running (or just-finished)
@@ -843,7 +843,7 @@ func (c *Client) GetWorkoutByID(ctx context.Context, workoutID int64) (garmin.Wo
- [ ] **Step 8: Rewrite `TestFillPendingDetails_ReportsLiveProgress` for phase-awareness, add two new tests**
Replace `TestFillPendingDetails_ReportsLiveProgress` in `backend/internal/sync/service_test.go`
Replace `TestFillPendingDetails_ReportsLiveProgress` in `../../../backend/internal/garmin/sync_test.go`
with:
```go
@@ -1045,9 +1045,9 @@ func TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass(t
}
```
`fmt` is already imported in `service_test.go`'s package (`package sync` imports it transitively
`fmt` is already imported in `sync_test.go`'s package (`package sync` imports it transitively
via other test helpers)? Check: if `go vet`/`go build` complains `fmt` is undefined in
`service_test.go`, add `"fmt"` to that file's import block.
`sync_test.go`, add `"fmt"` to that file's import block.
- [ ] **Step 9: Run the full `internal/sync` test suite**
@@ -1063,7 +1063,7 @@ Expected: all pass, `gofmt -l .` prints nothing.
- [ ] **Step 11: Commit**
```bash
git add backend/internal/sync/service.go backend/internal/sync/service_test.go backend/internal/sync/mapping.go backend/internal/garmin/mock/mock.go
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go backend/internal/sync/mapping.go backend/internal/garmin/mock/mock.go
git commit -m "$(cat <<'EOF'
feat(sync): split FillPendingDetails into activities/workouts phases
@@ -1095,7 +1095,7 @@ EOF
### Task 4: Reshape `GET /api/sync/status` and update frontend types
**Files:**
- Modify: `backend/internal/api/sync.go:69-102` (`handleSyncStatus`)
- Modify: `backend/internal/api/sync.go:69-102` (`handleGarminSyncStatus`)
- Modify: `backend/internal/api/api_test.go` (add a new test)
- Modify: `frontend/src/types/api.ts:197-207` (`DetailFillProgress`/`SyncStatus`, `SyncRun.Kind`)
@@ -1165,7 +1165,7 @@ handler doesn't produce `workouts_pending` or a nested `progress` object yet (st
- [ ] **Step 3: Update the handler**
In `backend/internal/api/sync.go`, replace `handleSyncStatus`:
In `backend/internal/api/sync.go`, replace `handleGarminSyncStatus`:
```go
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
@@ -1526,7 +1526,7 @@ EOF
- [ ] **Step 1: Replace the whole file**
`GarminConnection.tsx` keeps its connect/MFA/Reset-all logic completely unchanged. It loses:
`syncStatus` state, `syncStatusRef`, the `syncProgressLabel` helper, `syncStatus` polling inside
`garminSyncStatus` state, `syncStatusRef`, the `syncProgressLabel` helper, `garminSyncStatus` polling inside
`refreshStatus`/the mount `useEffect`, and all inline sync-progress/last-sync JSX. It gains a
`showSyncModal` boolean and renders `<SyncModal>` when true.

View File

@@ -368,7 +368,7 @@ import (
)
```
Add the `setupGarmin` field to `Server` and initialize it in `NewServer`:
Add the `setupSession` field to `Server` and initialize it in `NewServer`:
```go
mu sync.Mutex
@@ -395,7 +395,7 @@ Add the `setupGarmin` field to `Server` and initialize it in `NewServer`:
}
```
Insert this block right after `NewServer` (before `garminFor`):
Insert this block right after `NewServer` (before `clientFor`):
```go

View File

@@ -270,7 +270,7 @@ git commit -m "feat(store): add DeleteUser with cascading account deletion"
### Task 2: `DELETE /api/profile` and Garmin client/tokenstore teardown
**Files:**
- Modify: `backend/internal/api/server.go:5-21` (imports), `:95-117` (new `removeGarminClient` after `syncFor`), `:167-170` (route)
- Modify: `backend/internal/api/server.go:5-21` (imports), `:95-117` (new `removeUserClient` after `syncFor`), `:167-170` (route)
- Modify: `backend/internal/api/profile.go` (add `handleDeleteProfile`)
- Modify: `backend/internal/api/api_test.go` (imports + 3 new tests)
- Modify: `backend/internal/api/isolation_test.go` (1 new test)
@@ -427,7 +427,7 @@ func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
Run: `cd backend && go test ./internal/api/... -run 'TestDeleteProfile|TestIsolation_DeleteProfile' -v`
Expected: FAIL — 404s (no `DELETE /api/profile` route registered yet).
- [ ] **Step 3: Add the `os` import and `removeGarminClient` to `server.go`**
- [ ] **Step 3: Add the `os` import and `removeUserClient` to `server.go`**
Change the import block (add `"os"` between `"net/http"` and `"path/filepath"`):

View File

@@ -4,7 +4,7 @@
**Goal:** Emit structured JSON logs on stdout for (1) every HTTP API request and (2) every Garmin wrapper subprocess call, correlated by a per-request id — without touching any existing `log.Printf` call site.
**Architecture:** A new `internal/applog` package wraps `log/slog` with a JSON handler plus context helpers (`WithLogger`/`FromContext`). `internal/api` gains a `requestLoggingMiddleware` that logs one line per HTTP request and stashes a request-id-tagged logger into the request context. `internal/garmin`'s `client.go` reads that same logger back out of context (via the `ctx` every `Client` method already receives) inside `roundTrip`, the single funnel point all six Garmin calls go through, logging one line per subprocess round-trip.
**Architecture:** A new `internal/applog` package wraps `log/slog` with a JSON handler plus context helpers (`WithLogger`/`FromContext`). `internal/api` gains a `loggingMiddleware` that logs one line per HTTP request and stashes a request-id-tagged logger into the request context. `internal/garmin`'s `client.go` reads that same logger back out of context (via the `ctx` every `Client` method already receives) inside `roundTrip`, the single funnel point all six Garmin calls go through, logging one line per subprocess round-trip.
**Tech Stack:** Go stdlib `log/slog` (no new dependency), `github.com/go-chi/chi/v5/middleware` (already-available subpackage of the existing chi dependency).
@@ -20,7 +20,7 @@
**Files:**
- Create: `backend/internal/applog/applog.go`
- Create: `backend/internal/applog/applog_test.go`
- Create: `../../../backend/internal/log/log_test.go`
**Interfaces:**
- Produces: `func NewLogger(level string, w io.Writer) *slog.Logger`, `func WithLogger(ctx context.Context, logger *slog.Logger) context.Context`, `func FromContext(ctx context.Context) *slog.Logger` (never nil — falls back to `slog.Default()`).
@@ -91,7 +91,7 @@ func TestFromContext_DefaultsWhenNoneSet(t *testing.T) {
Run: `cd backend && go test ./internal/applog/... -v`
Expected: FAIL — package `internal/applog` doesn't exist yet (build failure).
- [ ] **Step 3: Create `backend/internal/applog/applog.go`**
- [ ] **Step 3: Create `../../../backend/internal/log/log.go`**
```go
// Package applog provides geniusrun's structured JSON logging: a
@@ -164,19 +164,19 @@ git commit -m "feat(applog): add JSON logger and context helpers"
### Task 2: HTTP access log middleware
**Files:**
- Modify: `backend/internal/config/config.go` (new `LogLevel` field)
- Modify: `backend/internal/config/config_test.go` (new tests)
- Modify: `../../../backend/internal/config/envconfig.go` (new `LogLevel` field)
- Modify: `../../../backend/internal/config/envconfig_test.go` (new tests)
- Modify: `backend/cmd/geniusrund/main.go` (wire up `slog.SetDefault`)
- Modify: `backend/internal/api/server.go` (`requestLoggingMiddleware`, registered in `Router()`)
- Modify: `backend/internal/api/server.go` (`loggingMiddleware`, registered in `Router()`)
- Modify: `backend/internal/api/api_test.go` (new tests)
**Interfaces:**
- Consumes: `applog.NewLogger`, `applog.WithLogger`, `applog.FromContext` (Task 1).
- Produces: `requestLoggingMiddleware` (unexported chi middleware in `internal/api`), `config.Config.LogLevel string`.
- Produces: `loggingMiddleware` (unexported chi middleware in `internal/api`), `config.Config.LogLevel string`.
- [ ] **Step 1: Write the failing config tests**
Append to `backend/internal/config/config_test.go`:
Append to `../../../backend/internal/config/envconfig_test.go`:
```go
func TestLoad_LogLevelDefaultsToInfo(t *testing.T) {
@@ -276,7 +276,7 @@ func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) {
Run: `cd backend && go test ./internal/config/... ./internal/api/... -run 'TestLoad_LogLevel|TestRequestLoggingMiddleware' -v`
Expected: FAIL — `LogLevel` field doesn't exist on `Config`; `applog` import unresolved; no log output produced (middleware doesn't exist).
- [ ] **Step 4: Add `LogLevel` to `config.go`**
- [ ] **Step 4: Add `LogLevel` to `envconfig.go`**
Change:
@@ -471,7 +471,7 @@ Expected: `gofmt -l .` empty; everything passes.
- [ ] **Step 9: Commit**
```bash
git add backend/internal/config/config.go backend/internal/config/config_test.go backend/cmd/geniusrund/main.go backend/internal/api/server.go backend/internal/api/api_test.go
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go backend/cmd/geniusrund/main.go backend/internal/api/server.go backend/internal/api/api_test.go
git commit -m "feat(api): add structured JSON access log with request-id correlation"
```

View File

@@ -37,8 +37,8 @@ missing" from "not yet fetched."
### Task 1: `wrapper.py` marks a 404 with `not_found: true`
**Files:**
- Modify: `backend/internal/garmin/pyscript/wrapper.py` (imports, `dispatch`)
- Modify: `backend/internal/garmin/pyscript/tests/test_wrapper.py` (new test)
- Modify: `../../../backend/internal/garmin/wrapper/wrapper.py` (imports, `dispatch`)
- Modify: `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py` (new test)
**Interfaces:**
- Consumes: `garminconnect.GarminConnectNotFoundError` (already installed as a dependency).
@@ -48,7 +48,7 @@ missing" from "not yet fetched."
- [ ] **Step 1: Write the failing test**
Add to `backend/internal/garmin/pyscript/tests/test_wrapper.py`:
Add to `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`:
```python
def test_call_marks_not_found_error_specifically():
@@ -77,14 +77,14 @@ def test_call_does_not_mark_other_errors_as_not_found():
Run: `cd backend/internal/garmin/pyscript && python3 -m pytest tests/test_wrapper.py -k not_found -v`
(use whatever Python interpreter this repo's wrapper tests normally run under -- see
`backend/internal/garmin/pyscript/.venv` if one exists, or the `GARMIN_WRAPPER_PYTHON` convention).
`../../../backend/internal/garmin/wrapper/.venv` if one exists, or the `GARMIN_WRAPPER_PYTHON` convention).
Expected: `test_call_marks_not_found_error_specifically` FAILS (`resp` has no `"not_found"` key
yet); `test_call_does_not_mark_other_errors_as_not_found` already passes (nothing to change for
that case).
- [ ] **Step 3: Update `dispatch()`**
In `backend/internal/garmin/pyscript/wrapper.py`, replace:
In `../../../backend/internal/garmin/wrapper/wrapper.py`, replace:
```python
from garminconnect import Garmin
@@ -144,7 +144,7 @@ Expected: all tests PASS, including both new ones and every existing test unchan
- [ ] **Step 5: Commit**
```bash
git add backend/internal/garmin/pyscript/wrapper.py backend/internal/garmin/pyscript/tests/test_wrapper.py
git add backend/internal/garmin/wrapper/wrapper.py backend/internal/garmin/wrapper/tests/test_wrapper.py
git commit -m "$(cat <<'EOF'
feat(garmin): mark a wrapper 404 with not_found in the error response
@@ -659,8 +659,8 @@ EOF
### Task 4: `fillPendingWorkouts` stops retrying a confirmed 404
**Files:**
- Modify: `backend/internal/sync/service.go` (`fillPendingWorkouts`)
- Modify: `backend/internal/sync/service_test.go` (new test)
- Modify: `../../../backend/internal/garmin/sync.go` (`fillPendingWorkouts`)
- Modify: `../../../backend/internal/garmin/sync_test.go` (new test)
**Interfaces:**
- Consumes: `garmin.ErrNotFound` (Task 2), `db.SetActivityWorkoutNotFound` (Task 3).
@@ -668,7 +668,7 @@ EOF
- [ ] **Step 1: Write the failing test**
Add to `backend/internal/sync/service_test.go`, right after
Add to `../../../backend/internal/garmin/sync_test.go`, right after
`TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass`:
```go
@@ -743,7 +743,7 @@ regardless of the error's nature).
- [ ] **Step 3: Update `fillPendingWorkouts`**
In `backend/internal/sync/service.go`, replace:
In `../../../backend/internal/garmin/sync.go`, replace:
```go
for i, a := range pending {
@@ -794,7 +794,7 @@ with:
}
```
Add `"errors"` to `service.go`'s import block.
Add `"errors"` to `sync.go`'s import block.
- [ ] **Step 4: Run the test to verify it passes**
@@ -817,7 +817,7 @@ Expected: all pass, `gofmt -l .` prints nothing.
- [ ] **Step 7: Commit**
```bash
git add backend/internal/sync/service.go backend/internal/sync/service_test.go
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go
git commit -m "$(cat <<'EOF'
fix(sync): stop retrying a workout Garmin confirms is gone

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@ This replaces the MCP transport with a small custom Python subprocess wrapper ar
- No change to what data geniusrun actually syncs/stores/classifies today — only the transport between Go and Python changes. `internal/sync`, `internal/classify`, `internal/store`, and the frontend are untouched.
- No security sandboxing of the generic dispatcher (see below) — the subprocess is spoken to only by geniusrun's own Go process over a private pipe it owns, not exposed to any other caller, so an open dispatch surface is an accepted risk, not a gap.
- No auto-restart-on-crash logic for the subprocess. Matches today's behavior: a dead subprocess surfaces as a Go error on next use; only `UpdateCredentials` explicitly tears down and respawns.
- No change to the per-user token store layout (`{GarminTokenStoreRoot}/{userID}`) or the multi-tenant `garminFor` caching in `api.Server`.
- No change to the per-user token store layout (`{GarminTokenStoreRoot}/{userID}`) or the multi-tenant `clientFor` caching in `api.Server`.
## Wire protocol
@@ -99,5 +99,5 @@ Go's line reader uses a raised buffer size (well above the default 64KB `bufio.S
## Rollout notes
- No data migration involved — this only changes how the Go backend talks to Garmin, not what's stored.
- Existing per-user token stores under `GarminTokenStoreRoot` are unaffected (same `GARMIN_TOKENSTORE` env var name passed to the subprocess, same directory layout) — no re-login required for existing users after cutover.
- Existing per-user token stores under `TokenStoreRoot` are unaffected (same `GARMIN_TOKENSTORE` env var name passed to the subprocess, same directory layout) — no re-login required for existing users after cutover.
- Deploy order: ship the new wrapper + Go client together as one change (interface unchanged, so this is an internal swap, not a rolling/compat-sensitive migration).

View File

@@ -85,7 +85,7 @@ A new middleware step runs immediately after the existing `RequireSession` (role
`internal/garmin.Client` and `internal/sync.Service` already take their config/dependencies as constructor params with no hidden global state, so per-user instantiation is mechanical:
- `api.Server`'s current single fixed `Garmin`/`Sync` fields and flat Garmin-auth-status fields (today: plain fields on `Server`, single-user assumption) become two mutex-guarded maps: `map[int64]*garmin.Client` and `map[int64]*sync.Service`, keyed by `user_id`, lazily constructed on first access for a given user.
- A per-user `garmin.Client` is built from that user's own `profile` row (`GarminEmail`/`GarminPassword`) and a per-user token store path: `filepath.Join(cfg.GarminTokenStoreRoot, strconv.FormatInt(userID, 10))`. `GarminTokenStore` in `internal/config` is renamed/repurposed to `GarminTokenStoreRoot` (a directory, not a single tokenstore path) to reflect this.
- A per-user `garmin.Client` is built from that user's own `profile` row (`GarminEmail`/`GarminPassword`) and a per-user token store path: `filepath.Join(cfg.GarminTokenStoreRoot, strconv.FormatInt(userID, 10))`. `GarminTokenStore` in `internal/config` is renamed/repurposed to `TokenStoreRoot` (a directory, not a single tokenstore path) to reflect this.
- `sync.Service.Progress()` becomes naturally per-user once each user has their own `Service` instance — no separate change needed there.
- `store.DB` stays a single shared connection against one SQLite file — this design keeps the single-shared-DB-with-`user_id`-columns approach, so no per-user DB file/connection is needed.
@@ -125,5 +125,5 @@ Every existing handler in `internal/api` that reads/writes `profile`, `activitie
## Rollout notes
- Before deploying: log into the current single-tenant build once, note your `sub` from `GET /api/session/me`, and set `GENIUSRUN_LEGACY_OWNER_OIDC_SUB` to it for the first startup after upgrading.
- `GARMIN_TOKENSTORE` env var / `GarminTokenStore` config field is repurposed as a root directory (`GarminTokenStoreRoot`) rather than a single tokenstore path — existing deployments need to point it at a directory rather than mcp-garmin's old single-file/dir location, and any already-cached Garmin session under the old path is effectively invalidated (that one user will need to log into Garmin again post-migration, once, under their new per-user subdirectory).
- `GARMIN_TOKENSTORE` env var / `GarminTokenStore` config field is repurposed as a root directory (`TokenStoreRoot`) rather than a single tokenstore path — existing deployments need to point it at a directory rather than mcp-garmin's old single-file/dir location, and any already-cached Garmin session under the old path is effectively invalidated (that one user will need to log into Garmin again post-migration, once, under their new per-user subdirectory).
- No frontend routing changes beyond the new setup screen — everything else (Dashboard, Review Queue, Plan stub, Profile) is unchanged in shape, just now reflecting whichever user is logged in.

View File

@@ -69,7 +69,7 @@ After the schema edit, regenerate `docs/DATABASE.md` via `go run
### Backend API
- **`handleAuthLogin`/`handleAuthMFA`** (`internal/api/auth.go`): right after
- **`handleGarminAuthLogin`/`handleGarminAuthMFA`** (`internal/api/auth.go`): right after
the existing `s.recordAuthResult(userID, res)` call, if `res.Status ==
garmin.AuthSuccess`, call `s.DB.MarkGarminConnected(r.Context(), userID)`.
A failure here is logged and does *not* fail the HTTP response -- the

View File

@@ -22,7 +22,7 @@ only production caller of the core logic) calls `backfillCore`/`incrementalSyncC
Only `internal/sync/service_test.go` still calls `Backfill`/`IncrementalSync`, which is the only
reason they're not already flagged as unused by the compiler.
Since this plan already restructures `service.go`'s progress model, remove these two dead
Since this plan already restructures `sync.go`'s progress model, remove these two dead
exported methods (and their now-inaccurate doc comments) as an early task, rewriting the tests
that called them to exercise the same behavior through `FullSync` or the `*Core` functions
directly (same package, so unexported functions are still directly testable) -- before any of
@@ -120,7 +120,7 @@ able to report it; the frontend type was just never updated to match).
## Frontend design
`GarminConnection.tsx` keeps its connect/MFA/Reset-all logic and buttons untouched, but loses
its `syncStatus` polling, `syncProgressLabel` helper, and all inline sync-progress/last-sync
its `garminSyncStatus` polling, `syncProgressLabel` helper, and all inline sync-progress/last-sync
JSX.
A new `SyncModal.tsx`:

View File

@@ -31,7 +31,7 @@ commit); it just no longer gates anything on its own.
### The core problem: Garmin auth needs an account today
`garminFor` builds a per-user `garmin.Client` keyed by a DB user id and
`clientFor` builds a per-user `garmin.Client` keyed by a DB user id and
reads Garmin credentials from that user's `profile` row. To authenticate
with Garmin *before* an account exists, onboarding needs a **temporary,
not-yet-persisted** Garmin session -- keyed by the OIDC subject (already
@@ -45,7 +45,7 @@ finish onboarding.
### Backend: ephemeral pre-account Garmin sessions
New type and `Server` field (`server.go`, alongside the existing
`userGarmin`/`userSync`/etc. per-user maps):
`userClient`/`userSync`/etc. per-user maps):
```go
// setupSession is a temporary, not-yet-persisted Garmin authentication
@@ -81,7 +81,7 @@ Helper methods on `Server` (`server.go`):
spawn new" semantics as `garmin.Client.UpdateCredentials`), builds a
fresh ephemeral client whose `TokenStorePath` is namespaced under
`{root}/setup/{sub}` (distinct from the permanent `{root}/{userID}`
namespacing `garminFor` uses, so two people onboarding concurrently never
namespacing `clientFor` uses, so two people onboarding concurrently never
collide, and so the ephemeral session's persisted Garmin tokens have
*some* home even though no user id exists yet).
- `recordSetupAuthResult(sub string, res garmin.AuthResult)` -- updates a
@@ -140,7 +140,7 @@ r.Route("/setup", func(r chi.Router) {
resumes the already-established Garmin session instead of requiring a
fresh login. Logged, not fatal, on error.
`internal/api/auth.go`'s existing `handleAuthLogin`/`handleAuthMFA`/`handleAuthStatus`
`internal/api/auth.go`'s existing `handleGarminAuthLogin`/`handleGarminAuthMFA`/`handleGarminAuthStatus`
(used by the Profile page's `GarminConnection.tsx` for reconnecting an
already-provisioned user) are untouched.
@@ -204,7 +204,7 @@ simpler than threading a separate `"mfa"` step through, and matches how
`/api/setup/complete` after a successful session creates the user with
`GarminEmail`/`GarminPassword`/`GarminConnectedAt` all set, and promotes
the *same* `*mock.Client` instance into `s.userGarmin[userID]` (asserted
via `garminFor` returning that exact pointer, and `ClosedCalled` still
via `clientFor` returning that exact pointer, and `ClosedCalled` still
false -- proving no redundant re-authentication happened);
`/api/setup/complete` with no prior login attempt → 409;
already-provisioned subject hitting either endpoint → 409; two different

View File

@@ -74,7 +74,7 @@ the `requireProvisionedUser` group (`server.go`). `userID` comes from
`client.UpdateCredentials` outside `s.mu`).
- Best-effort `os.RemoveAll` on `filepath.Join(s.GarminBase.TokenStorePath,
strconv.FormatInt(userID, 10))` when `TokenStorePath` is configured --
the same path `garminFor` computes when building a client. Log on
the same path `clientFor` computes when building a client. Log on
error; this is cleanup of an already-orphaned directory, not something
that should fail the request that already deleted the DB row.
4. Respond `204 No Content`.

View File

@@ -75,7 +75,7 @@ which falls back to this default.
### HTTP access log (`internal/api`)
A new `requestLoggingMiddleware`, registered as the **first** `r.Use(...)`
A new `loggingMiddleware`, registered as the **first** `r.Use(...)`
in `Router()` (ahead of `corsMiddleware`), so it wraps every request
including unauthenticated ones (login redirect, health check) and OPTIONS
preflights:

View File

@@ -128,7 +128,7 @@ Every current inline-error site, and what changes:
| `pages/Analysis.tsx` | Local `error` state, inline paragraph. | Same. |
| `components/TrainingTypesCard.tsx` | Local `error` state, inline paragraph. | Same. |
| `LoginGate.tsx` | Reads `?auth_error=` from the URL synchronously during render, shows `<p className="login-gate-error">` with a mapped message. | A mount effect reads `?auth_error=` once and calls `showError(mappedMessage)` (same `AUTH_ERROR_MESSAGES` mapping as today); the inline paragraph and its CSS class are removed. |
| `OnboardingWizard.tsx` | Single `error` state used for both field validation ("Please enter a display name.", "Please enter your Garmin email and password.") **and** real failures (Garmin login rejected, MFA rejected, `complete()` failing after a successful Garmin auth) — all rendered via the same `onboarding-wizard-error` paragraph, in 4 places. | **Split.** Field-validation messages stay exactly as they are today (local state, inline paragraph, tied to a specific input the user needs to fix — a transient top-of-page banner is the wrong medium for "you left a required field blank"). Real failures (`submitGarmin`'s catch, `submitMFA`'s catch, `complete()`'s catch) call `showError(...)` instead of `setError(...)`. The `complete()`-failure Retry button, which today is conditionally rendered on `error &&`, switches to a new local boolean `completeFailed` (set `true` in that catch block) — the button's visibility no longer depends on the error text still being present, since that text now lives only in the (transient, auto-dismissing) banner. |
| `OnboardingWizard.tsx` | Single `error` state used for both field validation ("Please enter a display name.", "Please enter your Garmin email and password.") **and** real failures (Garmin login rejected, MFA rejected, `complete()` failing after a successful Garmin auth) — all rendered via the same `onboarding-wizard-error` paragraph, in 4 places. | **Split.** Field-validation messages stay exactly as they are today (local state, inline paragraph, tied to a specific input the user needs to fix — a transient top-of-page banner is the wrong medium for "you left a required field blank"). Real failures (`submitGarmin`'s catch, `garminMFA`'s catch, `complete()`'s catch) call `showError(...)` instead of `setError(...)`. The `complete()`-failure Retry button, which today is conditionally rendered on `error &&`, switches to a new local boolean `completeFailed` (set `true` in that catch block) — the button's visibility no longer depends on the error text still being present, since that text now lives only in the (transient, auto-dismissing) banner. |
**Dead CSS removal:** once no `.tsx` file references them, delete the
`.error`, `.onboarding-wizard-error`, and `.login-gate-error` rules from

12
docs/workouts.yaml Normal file
View File

@@ -0,0 +1,12 @@
version: v1
name: geniusrun
templates:
- name: 'Recovery 30m'
difficulty: 1
kind: recovery
blocks:
- index: 1
reps: 1
block:
- kind: run
duration: 30m

View File

@@ -80,20 +80,20 @@ export const api = {
body: JSON.stringify({ display_name: displayName }),
}),
// Auth
login: () => request<AuthResponse>("/api/auth/login", { method: "POST" }),
submitMFA: (code: string) =>
request<AuthResponse>("/api/auth/mfa", { method: "POST", body: JSON.stringify({ code }) }),
authStatus: () => request<AuthResponse>("/api/auth/status"),
// Garmin Auth
garminLogin: () => request<AuthResponse>("/api/garmin/auth/login", { method: "POST" }),
garminMFA: (code: string) =>
request<AuthResponse>("/api/garmin/auth/mfa", { method: "POST", body: JSON.stringify({ code }) }),
garminStatus: () => request<AuthResponse>("/api/garmin/auth/status"),
// Sync
syncRun: () => request<{ status: string }>("/api/sync/run", { method: "POST" }),
// Garmin Sync
garminSyncRun: () => request<{ status: string }>("/api/garmin/sync/run", { method: "POST" }),
// Deletes every synced activity (and its laps/samples/kind assignments)
// and rewinds the backfill watermark -- destructive, gated by a
// confirmation in the UI.
resetSync: () => request<{ status: string }>("/api/sync/reset", { method: "POST" }),
syncRuns: () => request<SyncRun[]>("/api/sync/runs"),
syncStatus: () => request<SyncStatus>("/api/sync/status"),
garminSyncReset: () => request<{ status: string }>("/api/garmin/sync/reset", { method: "POST" }),
garminSyncRuns: () => request<SyncRun[]>("/api/garmin/sync/runs"),
garminSyncStatus: () => request<SyncStatus>("/api/garmin/sync/status"),
// Training types -- fixed taxonomy, no create/delete
listWorkoutKinds: (includeInactive = false) =>

View File

@@ -18,9 +18,9 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
// it's open, so this no longer needs the "poll faster while syncing"
// dynamic interval it used to have.
useEffect(() => {
api.authStatus().then(setAuth).catch((e) => showError(String(e)));
api.garminStatus().then(setAuth).catch((e) => showError(String(e)));
const interval = setInterval(() => {
api.authStatus().then(setAuth).catch(() => {});
api.garminStatus().then(setAuth).catch(() => {});
}, 6000);
return () => clearInterval(interval);
}, []);
@@ -29,7 +29,7 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
setBusy(true);
try {
await onBeforeConnect?.();
const result = await api.login();
const result = await api.garminLogin();
setAuth(result);
setDisconnected(false);
if (result.status === "authenticated") showSuccess("Connected to Garmin successfully.");
@@ -48,7 +48,7 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
if (!code.trim()) return;
setBusy(true);
try {
const result = await api.submitMFA(code.trim());
const result = await api.garminMFA(code.trim());
setAuth(result);
if (result.status === "authenticated") showSuccess("Connected to Garmin successfully.");
} catch (e) {
@@ -61,7 +61,7 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
async function sync() {
setBusy(true);
try {
await api.syncRun();
await api.garminSyncRun();
setShowSyncModal(true);
} catch (e) {
showError(String(e));
@@ -76,14 +76,14 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
}
setBusy(true);
try {
await api.resetSync();
await api.garminSyncReset();
// Reset runs as a background sync job; wait for it to actually finish
// before reloading, otherwise other pages (e.g. Activities) would
// still show the just-deleted activities from their own stale state.
let status = await api.syncStatus();
let status = await api.garminSyncStatus();
while (status.in_progress) {
await new Promise((resolve) => setTimeout(resolve, 300));
status = await api.syncStatus();
status = await api.garminSyncStatus();
}
window.location.reload();
} catch (e) {

View File

@@ -61,7 +61,7 @@ export function SyncModal({ onClose }: { onClose: () => void }) {
let timeout: ReturnType<typeof setTimeout>;
const tick = () => {
api
.syncStatus()
.garminSyncStatus()
.then((s) => {
setStatus(s);
if (!s.in_progress) {