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" "time"
"geniusrun/backend/internal/api" "geniusrun/backend/internal/api"
"geniusrun/backend/internal/applog"
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
"geniusrun/backend/internal/config" "geniusrun/backend/internal/config"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/log"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func main() { func main() {
cfg, err := config.Load() envCfg, err := config.LoadEnv()
if err != nil { if err != nil {
log.Fatalf("config: %v", err) 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 { if err != nil {
log.Fatalf("open database: %v", err) log.Fatalf("open database: %v", err)
} }
@@ -45,37 +44,43 @@ func main() {
} }
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{ authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
IssuerURL: cfg.OIDCIssuerURL, IssuerURL: envCfg.OIDCIssuerURL,
ClientID: cfg.OIDCClientID, ClientID: envCfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret, ClientSecret: envCfg.OIDCClientSecret,
RedirectURL: cfg.OIDCRedirectURL, RedirectURL: envCfg.OIDCRedirectURL,
RequiredRole: cfg.OIDCRequiredRole, RequiredRole: envCfg.OIDCRequiredRole,
}) })
if err != nil { if err != nil {
log.Fatalf("oidc: %v", err) log.Fatalf("oidc: %v", err)
} }
server := api.NewServer(db, garmin.NewClient, garmin.Config{ server := api.NewServer(
PythonPath: cfg.GarminPythonPath, db,
TokenStorePath: cfg.GarminTokenStoreRoot, garmin.NewClient,
}, appsync.Config{}, authVerifier, api.SessionConfig{ garmin.ClientConfig{
Secret: cfg.SessionSecret, PythonPath: envCfg.PythonPath,
Duration: appCfg.SessionDuration, TokenStorePath: envCfg.TokenStoreRoot,
Secure: cfg.SessionSecure, },
BackendURL: cfg.BackendURL, garmin.SyncConfig{},
FrontendURL: cfg.FrontendURL, 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}) server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value})
} }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()} httpServer := &http.Server{Addr: envCfg.BackendAddr, Handler: server.Router()}
go func() { 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 { if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err) log.Fatalf("http server: %v", err)
} }

View File

@@ -13,9 +13,8 @@ import (
"time" "time"
"geniusrun/backend/internal/classify" "geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin/mock" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func main() { func main() {
@@ -73,8 +72,8 @@ func main() {
IsActive: true, IsActive: true,
})) }))
m := &mock.Client{} m := &garmin.MockClient{}
svc := appsync.NewService(m, db, userID, appsync.Config{MinConfidence: 0.6}, nil) svc := garmin.NewSync(m, db, userID, garmin.SyncConfig{MinConfidence: 0.6}, nil)
today := time.Now() today := time.Now()
activityIDs := []int64{} 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" "testing"
"time" "time"
"geniusrun/backend/internal/applog"
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock" authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock" "geniusrun/backend/internal/log"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func newCtx() context.Context { return context.Background() } 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) t.Fatalf("ProvisionUser: %v", err)
} }
m := &mock.Client{} m := &garmin.MockClient{}
garminFactory := func(garmin.Config) garmin.Client { return m } garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
return s, db, userID return s, db, userID
} }
@@ -586,7 +584,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
} }
router := s.Router() 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 { if rec.Code != http.StatusAccepted {
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String()) 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. // "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 { 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) 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 { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) 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") 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 { if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String()) 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) { func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) {
s, _, userID := newTestServer(t) s, _, userID := newTestServer(t)
client, err := s.garminFor(context.Background(), userID) client, err := s.clientFor(context.Background(), userID)
if err != nil { if err != nil {
t.Fatalf("garminFor: %v", err) t.Fatalf("garminFor: %v", err)
} }
mockClient, ok := client.(*mock.Client) mockClient, ok := client.(*garmin.MockClient)
if !ok { 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"}} mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
router := s.Router() 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 { if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String()) 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() router := s.Router()
// Force the per-user Garmin client to be built and cached. // 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 { if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String()) 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 { if err != nil {
t.Fatalf("garminFor: %v", err) t.Fatalf("garminFor: %v", err)
} }
mockClient, ok := client.(*mock.Client) mockClient, ok := client.(*garmin.MockClient)
if !ok { 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) rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
@@ -882,9 +880,9 @@ func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) {
t.Fatalf("WriteFile: %v", err) t.Fatalf("WriteFile: %v", err)
} }
m := &mock.Client{} m := &garmin.MockClient{}
garminFactory := func(garmin.Config) garmin.Client { return m } garminFactory := func(garmin.ClientConfig) garmin.Client { 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)
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil) rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent { 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"` Value string `json:"value"`
} }
type appConfigEntry struct { type configEntry struct {
Key string `json:"key"` Key string `json:"key"`
Value string `json:"value"` Value string `json:"value"`
Default string `json:"default"` Default string `json:"default"`
@@ -30,13 +30,13 @@ func (s *Server) configView(r *http.Request) (map[string]any, error) {
return nil, err return nil, err
} }
registry := config.AppRegistry() registry := config.AppRegistry()
app := make([]appConfigEntry, 0, len(registry)) app := make([]configEntry, 0, len(registry))
for _, k := range registry { for _, k := range registry {
value, overridden := overrides[k.Key] value, overridden := overrides[k.Key]
if !overridden { if !overridden {
value = k.Default value = k.Default
} }
app = append(app, appConfigEntry{ app = append(app, configEntry{
Key: k.Key, Value: value, Default: k.Default, Key: k.Key, Value: value, Default: k.Default,
Overridden: overridden, Description: k.Description, Overridden: overridden, Description: k.Description,
}) })

View File

@@ -5,12 +5,9 @@ import (
"net/http" "net/http"
"testing" "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" authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
) )
type configTestResponse struct { type configTestResponse struct {
@@ -87,8 +84,8 @@ func TestConfig_RequiresProvisionedUser(t *testing.T) {
t.Fatalf("store.Open: %v", err) t.Fatalf("store.Open: %v", err)
} }
defer db.Close() defer db.Close()
m := &mock.Client{} m := &garmin.MockClient{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) 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 { if rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil); rec.Code != http.StatusForbidden {
t.Fatalf("GET status = %d, want 403", rec.Code) 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" "geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock" authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
// doJSONAs is doJSON but for an explicit session Sub, for tests that need // 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) t.Fatalf("store.Open: %v", err)
} }
defer db.Close() defer db.Close()
m := &mock.Client{} m := &garmin.MockClient{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) 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 rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusForbidden {
@@ -196,7 +194,7 @@ func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
} }
router := s.Router() 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 { if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String()) 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()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
client, err := s.garminFor(r.Context(), userID) client, err := s.clientFor(r.Context(), userID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -98,6 +98,6 @@ func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
s.removeGarminClient(userID) s.removeUserClient(userID)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }

View File

@@ -8,6 +8,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
applog "geniusrun/backend/internal/log"
"log" "log"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -18,35 +19,34 @@ import (
"sync/atomic" "sync/atomic"
"time" "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/auth"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store" "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 // 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 // and sync.Service are per-user (each user might have their own Garmin
// account), built lazily on first use via GarminFactory and cached. // account), built lazily on first use via GarminFactory and cached.
type Server struct { type Server struct {
DB *store.DB DB *store.DB
Auth auth.Verifier Auth auth.Verifier
Session SessionConfig SessionConfig SessionConfig
// GarminFactory builds a real (or fake, in tests) garmin.Client from a // GarminFactory builds a real (or fake, in tests) garmin.Client from a
// fully-resolved per-user Config. Production wiring passes // fully-resolved per-user Config. Production wiring passes
// garmin.NewClient; tests inject a factory returning a shared // garmin.NewClient; tests inject a factory returning a shared
// *mock.Client (see newTestServer in api_test.go). // *mock.Client (see newTestServer in api_test.go).
GarminFactory func(garmin.Config) garmin.Client GarminFactory func(garmin.ClientConfig) garmin.Client
// GarminBase holds the plumbing shared by every user's garmin.Config
// ClientConfig holds the plumbing shared by every user's garmin.Config
// (the python interpreter path + the token-store root directory); only // (the python interpreter path + the token-store root directory); only
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by // GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
// garminFor. // garminFor.
GarminBase garmin.Config ClientConfig garmin.ClientConfig
SyncConfig appsync.Config SyncConfig garmin.SyncConfig
// EnvVars is the read-only, display-safe environment-configuration // EnvVars is the read-only, display-safe environment-configuration
// snapshot served by GET /api/config -- built once in main.go from // snapshot served by GET /api/config -- built once in main.go from
@@ -55,260 +55,36 @@ type Server struct {
EnvVars []EnvVar EnvVars []EnvVar
mu sync.Mutex mu sync.Mutex
userGarmin map[int64]garmin.Client userClient map[int64]garmin.Client
userSync map[int64]*appsync.Service userSync map[int64]*garmin.Sync
userAuthStatus map[int64]garmin.AuthStatus userAuthStatus map[int64]garmin.AuthStatus
userAuthMessage map[int64]string userAuthMessage map[int64]string
userSyncRunning map[int64]bool userSyncRunning map[int64]bool
setupGarmin map[string]*setupSession setupSession map[string]*setupSession
} }
// NewServer builds a Server. // 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{ return &Server{
DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig, DB: db,
Auth: authVerifier, Session: session, GarminFactory: garminFactory,
userGarmin: map[int64]garmin.Client{}, ClientConfig: garminBase,
userSync: map[int64]*appsync.Service{}, SyncConfig: syncConfig,
Auth: authVerifier,
SessionConfig: sessionConfig,
userClient: map[int64]garmin.Client{},
userSync: map[int64]*garmin.Sync{},
userAuthStatus: map[int64]garmin.AuthStatus{}, userAuthStatus: map[int64]garmin.AuthStatus{},
userAuthMessage: map[int64]string{}, userAuthMessage: map[int64]string{},
userSyncRunning: map[int64]bool{}, 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. // Router builds the HTTP routes.
func (s *Server) Router() http.Handler { func (s *Server) Router() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(requestLoggingMiddleware) r.Use(loggingMiddleware)
r.Use(corsMiddleware) r.Use(corsMiddleware)
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
r.Get("/health", s.handleHealth) r.Get("/health", s.handleHealth)
@@ -319,11 +95,12 @@ func (s *Server) Router() http.Handler {
r.Get("/session/callback", s.handleSessionCallback) r.Get("/session/callback", s.handleSessionCallback)
r.Group(func(r chi.Router) { 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.Use(s.resolveUser)
r.Get("/session/me", s.handleSessionMe) r.Get("/session/me", s.handleSessionMe)
r.Post("/session/logout", s.handleSessionLogout) r.Post("/session/logout", s.handleSessionLogout)
r.Route("/setup", func(r chi.Router) { r.Route("/setup", func(r chi.Router) {
r.Post("/complete", s.handleSetupComplete) r.Post("/complete", s.handleSetupComplete)
r.Route("/garmin", func(r chi.Router) { r.Route("/garmin", func(r chi.Router) {
@@ -341,17 +118,20 @@ func (s *Server) Router() http.Handler {
r.Delete("/", s.handleDeleteProfile) r.Delete("/", s.handleDeleteProfile)
}) })
r.Route("/auth", func(r chi.Router) { r.Route("/garmin", func(r chi.Router) {
r.Post("/login", s.handleAuthLogin) r.Route("/auth", func(r chi.Router) {
r.Post("/mfa", s.handleAuthMFA) r.Post("/login", s.handleGarminAuthLogin)
r.Get("/status", s.handleAuthStatus) 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) { r.Route("/activities", func(r chi.Router) {
@@ -379,6 +159,40 @@ func (s *Server) Router() http.Handler {
return r 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 // corsMiddleware allows the frontend dev server (a different port) to call
// this API. Reflecting any origin back is safe even with credentials // this API. Reflecting any origin back is safe even with credentials
// enabled: this remains a single-operator app whose real access control is // 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) { // clientFor returns userID's garmin.Client, building and caching it (from
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) // 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) { // removeUserClient drops userID's cached garmin.Client/sync.Service (if
w.Header().Set("Content-Type", "application/json") // any) and every other per-user in-memory entry for userID, terminating the
w.WriteHeader(status) // client's subprocess and best-effort removing its on-disk token-store
if err := json.NewEncoder(w).Encode(v); err != nil { // directory. Called when a user's account has just been deleted from the
log.Printf("api: encode response: %v", err) // 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) { // syncFor returns userID's sync.Service, building and caching it on first use.
writeJSON(w, status, map[string]string{"error": msg}) 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 // 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 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()) writeError(w, http.StatusBadGateway, err.Error())
return 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -58,36 +58,36 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
txnCookie, err := r.Cookie(auth.TxnCookieName) txnCookie, err := r.Cookie(auth.TxnCookieName)
if err != nil { if err != nil {
log.Printf("session callback: missing txn cookie: %v", err) 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 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 { if err != nil {
log.Printf("session callback: failed to parse txn cookie: %v", err) 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 return
} }
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query()) result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
if err != nil { if err != nil {
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", err) 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 return
} }
if !result.Authorized { 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 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
http.SetCookie(w, sessionCookie) 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 // 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) { func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
claims, _ := auth.ClaimsFromContext(r.Context()) claims, _ := auth.ClaimsFromContext(r.Context())
s.removeSetupSession(claims.Sub) s.removeSetupSession(claims.Sub)
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure)) http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.SessionConfig.Secure))
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/", claims.IDToken), http.StatusFound) http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", claims.IDToken), http.StatusFound)
} }
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {

View File

@@ -7,11 +7,38 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time"
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin" "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, // handleSetupGarminLogin authenticates against Garmin using an ephemeral,
// not-yet-persisted session keyed by the OIDC subject -- no users/profile // not-yet-persisted session keyed by the OIDC subject -- no users/profile
// row exists yet at this point (see setupSession in server.go). // 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 // garmin.AuthSuccess. Provisions the account, persists the Garmin
// credentials, marks it connected, closes the ephemeral client, and // credentials, marks it connected, closes the ephemeral client, and
// renames its token-store directory into the permanent per-user path -- // 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 // against that now-permanent directory, whose subprocess's lazy
// startup-login resumes the just-renamed, still-valid session without // startup-login resumes the just-renamed, still-valid session without
// needing to re-authenticate (a cheap local token-store resume, not a // 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() sess.Client.Close()
s.mu.Lock() s.mu.Lock()
delete(s.setupGarmin, claims.Sub) delete(s.setupSession, claims.Sub)
s.userAuthStatus[userID] = sess.Status s.userAuthStatus[userID] = sess.Status
s.userAuthMessage[userID] = sess.Message s.userAuthMessage[userID] = sess.Message
s.mu.Unlock() s.mu.Unlock()
if s.GarminBase.TokenStorePath != "" { if s.ClientConfig.TokenStorePath != "" {
oldDir := setupTokenStoreDir(s.GarminBase.TokenStorePath, claims.Sub) oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub)
newDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10)) newDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) { 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) 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}) 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" authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
// newUnprovisionedServer builds a Server whose "test-user" OIDC subject // newUnprovisionedServer builds a Server whose "test-user" OIDC subject
// (the identity doJSON's cookie always mints) has no users/profile row yet // (the identity doJSON's cookie always mints) has no users/profile row yet
// -- every test in this file needs that starting state, unlike // -- every test in this file needs that starting state, unlike
// newTestServer's auto-provisioned default. // 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() t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil { if err != nil {
t.Fatalf("store.Open: %v", err) t.Fatalf("store.Open: %v", err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
m := &mock.Client{} m := &garmin.MockClient{}
garminFactory := func(garmin.Config) garmin.Client { return m } garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
return s, db, m return s, db, m
} }
@@ -98,14 +96,14 @@ func TestSetupComplete_CreatesAccountWithGarminCredentialsAndClosesEphemeralClie
t.Fatalf("store.Open: %v", err) t.Fatalf("store.Open: %v", err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
m := &mock.Client{} m := &garmin.MockClient{}
tokenStoreRoot := t.TempDir() tokenStoreRoot := t.TempDir()
var factoryConfigs []garmin.Config var factoryConfigs []garmin.ClientConfig
garminFactory := func(cfg garmin.Config) garmin.Client { garminFactory := func(cfg garmin.ClientConfig) garmin.Client {
factoryConfigs = append(factoryConfigs, cfg) factoryConfigs = append(factoryConfigs, cfg)
return m 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() router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ 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 // A later real use must build a genuinely fresh client, configured
// against the permanent {userID} token store directory -- never the // against the permanent {userID} token store directory -- never the
// stale ephemeral setup/{hash} one the closed client was carrying. // 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) t.Fatalf("garminFor: %v", err)
} }
wantTokenStorePath := filepath.Join(tokenStoreRoot, itoa(u.ID)) wantTokenStorePath := filepath.Join(tokenStoreRoot, itoa(u.ID))
@@ -210,9 +208,9 @@ func TestSetupComplete_RenamesTokenStoreDirectory(t *testing.T) {
t.Fatalf("store.Open: %v", err) t.Fatalf("store.Open: %v", err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
m := &mock.Client{} m := &garmin.MockClient{}
tokenStoreRoot := t.TempDir() 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() router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{ 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" "geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock" authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) { func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
@@ -41,8 +39,8 @@ func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) {
t.Fatalf("store.Open: %v", err) t.Fatalf("store.Open: %v", err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
m := &mock.Client{} m := &garmin.MockClient{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
var gotOK bool var gotOK bool
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 // LoadApp merges overrides (store.ConfigValues' map) over defaults. Pure
// -- it takes the raw map instead of a *store.DB so this package needs no // -- 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 // 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) { func LoadApp(overrides map[string]string) (AppConfig, error) {
merged := map[string]string{} merged := map[string]string{}
for _, k := range appRegistry { for _, k := range appRegistry {

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ import (
"testing" "testing"
"time" "time"
"geniusrun/backend/internal/applog" "geniusrun/backend/internal/log"
) )
// wireResponsePayload is what a fake wrapper handler returns for one // 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) { 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.started = true // simulate an already-spawned subprocess, no real cmd/pipes
c.UpdateCredentials("new@example.com", "new") c.UpdateCredentials("new@example.com", "new")

View File

@@ -1,4 +1,4 @@
package sync package garmin
import ( import (
"encoding/json" "encoding/json"
@@ -6,7 +6,6 @@ import (
"time" "time"
"geniusrun/backend/internal/classify" "geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
) )
@@ -19,7 +18,7 @@ func isRunningActivityType(typeKey string) bool {
return strings.Contains(strings.ToLower(typeKey), "run") return strings.Contains(strings.ToLower(typeKey), "run")
} }
func toActivityRow(a garmin.Activity) store.Activity { func toActivityRow(a Activity) store.Activity {
return store.Activity{ return store.Activity{
GarminActivityID: a.ActivityID, GarminActivityID: a.ActivityID,
EventTypeKey: a.EventType.TypeKey, 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 // fall within that lap's time window. Lap boundaries are derived from
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps // cumulative elapsed duration rather than parsing StartTimeGMT, since laps
// are contiguous and this sidesteps timezone parsing entirely. // 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)) rows := make([]store.Lap, 0, len(laps))
var elapsedStart float64 var elapsedStart float64
for i, l := range laps { 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, // 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 // etc.) can't be trusted at all, so every entry comes back nil rather than
// risk showing a target against the wrong lap. // 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() steps := workout.FlattenSteps()
out := make([]*garmin.WorkoutStep, lapCount) out := make([]*WorkoutStep, lapCount)
switch lapCount - len(steps) { switch lapCount - len(steps) {
case 0, 1: 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 // 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. // 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 { if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil {
return nil, 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. // 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 // Steps that target a named zone (ZoneNumber) rather than a custom bpm
// range are resolved via the user's Karvonen profile. // 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" { if step.TargetType.TypeKey != "heart.rate.zone" {
return nil, nil 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 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 var out []classify.SampleInfo
for _, s := range samples { for _, s := range samples {
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end { if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
@@ -199,7 +198,7 @@ func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.Sam
return out return out
} }
func toSampleRows(samples []garmin.Sample) []store.Sample { func toSampleRows(samples []Sample) []store.Sample {
rows := make([]store.Sample, len(samples)) rows := make([]store.Sample, len(samples))
for i, s := range samples { for i, s := range samples {
rows[i] = store.Sample{ 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. // work without a live Garmin account or the wrapper subprocess.
package mock package garmin
import ( import (
"context" "context"
"time" "time"
"geniusrun/backend/internal/garmin"
) )
// Client is a fake garmin.Client returning data supplied by the test/caller. // MockClient is a fake Client returning data supplied by the test/caller.
type Client struct { type MockClient struct {
AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls AuthResults []AuthResult // consumed in order by Authenticate/CompleteMFA calls
Activities []garmin.Activity Activities []Activity
Splits map[int64]garmin.ActivitySplits Splits map[int64]ActivitySplits
Details map[int64]garmin.ActivityDetails Details map[int64]ActivityDetails
Workouts map[int64]garmin.Workout Workouts map[int64]Workout
// WorkoutErrByID, if set for a given workout ID, makes GetWorkoutByID // WorkoutErrByID, if set for a given workout ID, makes GetWorkoutByID
// return that error for that ID specifically -- independent of the // return that error for that ID specifically -- independent of the
// all-calls-fail Err field below -- so a test can simulate one // all-calls-fail Err field below -- so a test can simulate one
@@ -37,33 +35,33 @@ type Client struct {
LastPassword string 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) { 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] r := c.AuthResults[c.authResultCursor]
c.authResultCursor++ c.authResultCursor++
return r return r
} }
func (c *Client) Authenticate(ctx context.Context) (garmin.AuthResult, error) { func (c *MockClient) Authenticate(ctx context.Context) (AuthResult, error) {
c.AuthenticateCalls++ c.AuthenticateCalls++
if c.Err != nil { if c.Err != nil {
return garmin.AuthResult{}, c.Err return AuthResult{}, c.Err
} }
return c.nextAuthResult(), nil 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 { if c.Err != nil {
return garmin.AuthResult{}, c.Err return AuthResult{}, c.Err
} }
return c.nextAuthResult(), nil 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++ c.GetActivitiesCalls++
if c.Delay > 0 { if c.Delay > 0 {
select { select {
@@ -81,36 +79,36 @@ func (c *Client) GetActivities(ctx context.Context, startDate, endDate string, l
return c.Activities, nil 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 { if c.Err != nil {
return garmin.ActivitySplits{}, c.Err return ActivitySplits{}, c.Err
} }
return c.Splits[activityID], nil 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 { if c.Err != nil {
return garmin.ActivityDetails{}, c.Err return ActivityDetails{}, c.Err
} }
return c.Details[activityID], nil 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 { if c.Err != nil {
return garmin.Workout{}, c.Err return Workout{}, c.Err
} }
if err, ok := c.WorkoutErrByID[workoutID]; ok { if err, ok := c.WorkoutErrByID[workoutID]; ok {
return garmin.Workout{}, err return Workout{}, err
} }
return c.Workouts[workoutID], nil return c.Workouts[workoutID], nil
} }
func (c *Client) UpdateCredentials(email, password string) { func (c *MockClient) UpdateCredentials(email, password string) {
c.LastEmail = email c.LastEmail = email
c.LastPassword = password c.LastPassword = password
} }
func (c *Client) Close() error { func (c *MockClient) Close() error {
c.ClosedCalled = true c.ClosedCalled = true
return nil 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 // internal/garmin), persisting them (via internal/store), and classifying
// them (via internal/classify). It's the only package that depends on all // them (via internal/classify). It's the only package that depends on all
// three, keeping garmin/store/classify decoupled from each other. // three, keeping garmin/store/classify decoupled from each other.
package sync package garmin
import ( import (
"context" "context"
@@ -14,15 +14,14 @@ import (
"time" "time"
"geniusrun/backend/internal/classify" "geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
) )
// Config tunes sync behavior. Zero values fall back to sensible defaults in // 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 // Profile.BackfillHorizonDays, read fresh on every call so a user-edited
// value takes effect on the next sync without a server restart. // 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 // BackfillWindowDays is the page size for each get_activities call
// during backfill. // during backfill.
BackfillWindowDays int BackfillWindowDays int
@@ -39,7 +38,7 @@ type Config struct {
MinConfidence float64 MinConfidence float64
} }
func (c Config) withDefaults() Config { func (c SyncConfig) withDefaults() SyncConfig {
if c.BackfillWindowDays == 0 { if c.BackfillWindowDays == 0 {
c.BackfillWindowDays = 90 c.BackfillWindowDays = 90
} }
@@ -76,37 +75,37 @@ type Progress struct {
Total int 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. // it makes is for userID's data only.
type Service struct { type Sync struct {
garmin garmin.Client garmin Client
db *store.DB db *store.DB
userID int64 userID int64
cfg Config cfg SyncConfig
now func() time.Time now func() time.Time
progressMu sync.Mutex progressMu sync.Mutex
progress Progress 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). // 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 { if now == nil {
now = time.Now 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 // Progress returns the current sync progress (phase idle, 0/0 when nothing
// is running). // is running).
func (s *Service) Progress() Progress { func (s *Sync) Progress() Progress {
s.progressMu.Lock() s.progressMu.Lock()
defer s.progressMu.Unlock() defer s.progressMu.Unlock()
return s.progress 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.progressMu.Lock()
s.progress = Progress{Phase: phase, Done: done, Total: total} s.progress = Progress{Phase: phase, Done: done, Total: total}
s.progressMu.Unlock() 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 // combined SyncRun; there is no standalone entrypoint for this anymore
// (the periodic background sync loop that used to call one is gone -- see // (the periodic background sync loop that used to call one is gone -- see
// 4d2cbe4 refactor: remove automatic background incremental sync). // 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) profile, err := s.db.GetProfile(ctx, s.userID)
if err != nil { if err != nil {
return 0, fmt.Errorf("load profile: %w", err) 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 // activity (or a short recent window if none exist yet) through today. Used
// by FullSync as one step of its single combined SyncRun -- see // by FullSync as one step of its single combined SyncRun -- see
// backfillCore's comment for why there's no standalone entrypoint. // 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) start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok { 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 { 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 -- // new since the latest known activity), then FillPendingDetails --
// recorded as a single SyncRun so the reported activity count covers the // recorded as a single SyncRun so the reported activity count covers the
// whole action instead of only whichever stage happened to finish last. // 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) runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull)
if err != nil { if err != nil {
return err 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 // assignments) and rewinds the backfill watermark, so the next Backfill
// call performs a genuinely fresh pull from Garmin instead of resuming from // call performs a genuinely fresh pull from Garmin instead of resuming from
// wherever the previous one left off. Workout kinds are left untouched. // 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) 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 // produced a confusing, meaningless number (e.g. "2 activities" on a sync
// that found nothing new, just because 2 already-known activities happened // that found nothing new, just because 2 already-known activities happened
// to fall inside the queried window). // 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) activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err) 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 // (re)classifies every activity touched by the first pass. Each pass makes
// its Garmin calls sequentially with Config.InterCallDelay between them to // its Garmin calls sequentially with Config.InterCallDelay between them to
// avoid Garmin/Cloudflare rate limiting. // 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) defer s.setProgress(PhaseIdle, 0, 0)
if err := s.fillPendingActivityDetails(ctx, limit); err != nil { 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) 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) pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit)
if err != nil { if err != nil {
return err 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 // bands are enrichment, not core activity data, and workout_raw_json
// staying NULL means ActivitiesMissingWorkout will naturally retry it on // staying NULL means ActivitiesMissingWorkout will naturally retry it on
// the next sync. // 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) pending, err := s.db.ActivitiesMissingWorkout(ctx, s.userID, limit)
if err != nil { if err != nil {
return err 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 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 // A definitive 404 (the workout was deleted on Garmin's side
// after being linked to this activity) will never succeed on // after being linked to this activity) will never succeed on
// retry -- mark it so ActivitiesMissingWorkout stops // retry -- mark it so ActivitiesMissingWorkout stops
@@ -382,7 +381,7 @@ func (s *Service) fillPendingWorkouts(ctx context.Context, limit int) error {
return nil 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) splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID)
if err != nil { if err != nil {
return fmt.Errorf("get_activity_splits: %w", err) 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) 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 { if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil {
return err return err
} }
// No workout-target alignment here -- that's fillActivityWorkout's job, // No workout-target alignment here -- that's fillActivityWorkout's job,
// run as its own later pass (see fillPendingWorkouts). targets is an // run as its own later pass (see fillPendingWorkouts). targets is an
// all-nil placeholder the same length as splits.Laps. // 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 { if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
return err 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 // fillActivityDetails, in some earlier pass or run) rather than needing the
// original garmin.Lap data again, since alignWorkoutTargets only needs a // original garmin.Lap data again, since alignWorkoutTargets only needs a
// count. // 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) workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID)
if err != nil { if err != nil {
return fmt.Errorf("get_workout_by_id: %w", err) 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 // ClassifyActivity (re)runs the rule engine for one activity against the
// currently active workout kinds and appends a new kind_assignments row. // currently active workout kinds and appends a new kind_assignments row.
// Safe to call repeatedly (e.g. after editing a workout kind's rule). // 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) activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID)
if err != nil { if err != nil {
return err return err

View File

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

View File

@@ -7,10 +7,17 @@ writing-plans flow (see `docs/superpowers/specs/` and `docs/superpowers/plans/`
for that history) and remove it from here once a spec exists. for that history) and remove it from here once a spec exists.
## Backlog ## 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 - new workout kinds
- add "Recovery", "Quick", and "Sprint" 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 - 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) - 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 - 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) - 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) - 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)
@@ -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 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) - 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 - add icon to indicate if an activity has an associated workout
- - manage 2 layouts
- list layout with smaller graphs (no scale, no targets, no , detailed view with different graphs for each phase incl. delta with workout) - 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 ## 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 ### Task 7: Classification reads max HR from the profile, not static config
**Files:** **Files:**
- Modify: `backend/internal/sync/service.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310) - Modify: `../../../backend/internal/garmin/sync.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310)
- Modify: `backend/internal/sync/service_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`) - Modify: `../../../backend/internal/garmin/sync_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`)
**Interfaces:** **Interfaces:**
- Consumes: `store.Profile.MaxHeartRate` (Task 1), `db.GetProfile` (Task 1). - 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** - [ ] **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 ```go
svc := NewService(m, db, Config{MinConfidence: 0.5, MaxHR: 190}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) 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`** - [ ] **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 ```go
// MinConfidence is the classify.Classify threshold below which even a // 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** - [ ] **Step 6: Commit**
```bash ```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" 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 ### Task 8: `internal/config` and `cmd/geniusrund` — credentials come from the profile
**Files:** **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) - Modify: `backend/cmd/geniusrund/main.go` (lines 21-46)
**Interfaces:** **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`** - [ ] **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 ```go
// Package config loads geniusrund's runtime infrastructure configuration // 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** - [ ] **Step 5: Commit**
```bash ```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" 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 ### Task 1: Config — OIDC/session settings
**Files:** **Files:**
- Modify: `backend/internal/config/config.go` - Modify: `../../../backend/internal/config/envconfig.go`
- Create: `backend/internal/config/config_test.go` - Create: `../../../backend/internal/config/envconfig_test.go`
**Interfaces:** **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`. - 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** - [ ] **Step 1: Write the failing tests**
Create `backend/internal/config/config_test.go`: Create `../../../backend/internal/config/envconfig_test.go`:
```go ```go
package config 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** - [ ] **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 ```go
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally // OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
@@ -256,7 +256,7 @@ Expected: PASS (all `TestLoad_*` cases).
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```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" 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. - `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`). - `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. - `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 ### Task 1: Python wrapper (`wrapper.py`) with its own test suite
**Files:** **Files:**
- Create: `backend/internal/garmin/pyscript/wrapper.py` - Create: `../../../backend/internal/garmin/wrapper/wrapper.py`
- Create: `backend/internal/garmin/pyscript/pyproject.toml` - Create: `../../../backend/internal/garmin/wrapper/pyproject.toml`
- Create: `backend/internal/garmin/pyscript/.gitignore` - Create: `../../../backend/internal/garmin/wrapper/.gitignore`
- Test: `backend/internal/garmin/pyscript/tests/__init__.py` - Test: `../../../backend/internal/garmin/wrapper/tests/__init__.py`
- Test: `backend/internal/garmin/pyscript/tests/test_wrapper.py` - Test: `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`
**Interfaces:** **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. - 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** - [ ] **Step 1: Create the Python project manifest**
`backend/internal/garmin/pyscript/pyproject.toml`: `../../../backend/internal/garmin/wrapper/pyproject.toml`:
```toml ```toml
[project] [project]
@@ -55,7 +55,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
``` ```
`backend/internal/garmin/pyscript/.gitignore`: `../../../backend/internal/garmin/wrapper/.gitignore`:
``` ```
.venv/ .venv/
@@ -68,7 +68,7 @@ __pycache__/
Run: Run:
```bash ```bash
cd backend/internal/garmin/pyscript cd backend/internal/garmin/wrapper
python3 -m venv .venv python3 -m venv .venv
.venv/bin/pip install -e ".[dev]" .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** - [ ] **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 ```python
import os import os
@@ -216,7 +216,7 @@ Expected: `ModuleNotFoundError: No module named 'wrapper'` (or collection failur
- [ ] **Step 5: Write `wrapper.py`** - [ ] **Step 5: Write `wrapper.py`**
`backend/internal/garmin/pyscript/wrapper.py`: `../../../backend/internal/garmin/wrapper/wrapper.py`:
```python ```python
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited """Subprocess wrapper around garminconnect, spoken to over newline-delimited
@@ -393,7 +393,7 @@ Expected: all tests PASS.
- [ ] **Step 7: Commit** - [ ] **Step 7: Commit**
```bash ```bash
git add backend/internal/garmin/pyscript git add backend/internal/garmin/wrapper
git commit -m "$(cat <<'EOF' git commit -m "$(cat <<'EOF'
feat(garmin): add direct garminconnect wrapper script feat(garmin): add direct garminconnect wrapper script
@@ -584,7 +584,7 @@ Expected: compile failure — `subprocessClient`, `wireResponse`, `roundTrip`, `
```go ```go
// Package garmin wraps a direct garminconnect subprocess (see // 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. // geniusrun never deals with the wire protocol directly.
package garmin package garmin
@@ -861,7 +861,7 @@ git add backend/internal/garmin/client.go backend/internal/garmin/client_test.go
git commit -m "$(cat <<'EOF' git commit -m "$(cat <<'EOF'
feat(garmin): replace mcp-go transport with a JSON-lines subprocess client 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 speaks newline-delimited JSON instead of MCP. Auth/data methods land in
follow-up commits; this is the transport + lifecycle plumbing only. 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) ### Task 5: Update `internal/config` (drop server-path env var, default the python path)
**Files:** **Files:**
- Modify: `backend/internal/config/config.go` - Modify: `../../../backend/internal/config/envconfig.go`
- Modify: `backend/internal/config/config_test.go` - Modify: `../../../backend/internal/config/envconfig_test.go`
**Interfaces:** **Interfaces:**
- Consumes: nothing from Tasks 14. - Consumes: nothing from Tasks 14.
@@ -1379,7 +1379,7 @@ EOF
- [ ] **Step 1: Write the failing tests** - [ ] **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 ```go
func setRequiredEnv(t *testing.T) { func setRequiredEnv(t *testing.T) {
@@ -1425,11 +1425,11 @@ func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
- [ ] **Step 2: Run the tests to verify they fail** - [ ] **Step 2: Run the tests to verify they fail**
Run: `cd backend && go test ./internal/config/... -v` 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 ```go
// GarminPythonPath is mcp-garmin's venv python executable. // GarminPythonPath is mcp-garmin's venv python executable.
@@ -1479,7 +1479,7 @@ Expected: all tests PASS.
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```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' git commit -m "$(cat <<'EOF'
feat(config): drop MCP_GARMIN_SERVER, default GARMIN_WRAPPER_PYTHON feat(config): drop MCP_GARMIN_SERVER, default GARMIN_WRAPPER_PYTHON
@@ -1528,7 +1528,7 @@ to:
}, appsync.Config{ }, 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: In `backend/internal/api/server.go`, change:
@@ -1749,4 +1749,4 @@ EOF
## Follow-up note (not a task in this plan) ## 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` ### Task 1: Add `FrontendURL` to `internal/config`
**Files:** **Files:**
- Modify: `backend/internal/config/config.go` - Modify: `../../../backend/internal/config/envconfig.go`
- Modify: `backend/internal/config/config_test.go` - Modify: `../../../backend/internal/config/envconfig_test.go`
**Interfaces:** **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. - 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** - [ ] **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 ```go
func TestLoad_FrontendURLDefaultsToPublicBaseURL(t *testing.T) { 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** - [ ] **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 ```go
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally // 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** - [ ] **Step 5: Commit**
```bash ```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' git commit -m "$(cat <<'EOF'
feat(config): add GENIUSRUN_FRONTEND_URL, defaulting to PublicBaseURL feat(config): add GENIUSRUN_FRONTEND_URL, defaulting to PublicBaseURL

View File

@@ -2223,14 +2223,14 @@ EOF
## Task 10: `internal/sync.Service` becomes per-user ## Task 10: `internal/sync.Service` becomes per-user
**Files:** **Files:**
- Modify: `backend/internal/sync/service.go` - Modify: `../../../backend/internal/garmin/sync.go`
- Modify: `backend/internal/sync/service_test.go` - Modify: `../../../backend/internal/garmin/sync_test.go`
**Interfaces:** **Interfaces:**
- Consumes: every scoped store method from Tasks 4-8. - 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. - 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`: 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)`. - `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{...})`. - `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`): 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. Expected: no output.
```bash ```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' git commit -m "$(cat <<'EOF'
sync: scope Service to one user per instance sync: scope Service to one user per instance
@@ -2372,12 +2372,12 @@ EOF
## Task 11: `internal/config` additions ## Task 11: `internal/config` additions
**Files:** **Files:**
- Modify: `backend/internal/config/config.go` - Modify: `../../../backend/internal/config/envconfig.go`
**Interfaces:** **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. - 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: In the `Config` struct, change:
@@ -2427,7 +2427,7 @@ Run: `cd backend && gofmt -l internal/config/`
Expected: no output. Expected: no output.
```bash ```bash
git add backend/internal/config/config.go git add backend/internal/config/envconfig.go
git commit -m "$(cat <<'EOF' git commit -m "$(cat <<'EOF'
config: add per-user Garmin token store root and legacy-owner bootstrap var 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` ## Task 12: User-resolution middleware + `POST /api/setup`
**Files:** **Files:**
- Create: `backend/internal/api/usercontext.go` - Create: `../../../backend/internal/api/user.go`
- Create: `backend/internal/api/setup.go` - Create: `backend/internal/api/setup.go`
- Modify: `backend/internal/api/session.go` (extend `sessionMeResponse`, `handleSessionMe`) - 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` - Create: `backend/internal/api/setup_test.go`
**Interfaces:** **Interfaces:**
- Consumes: `store.GetUserBySub`/`store.ProvisionUser` (Task 2), `auth.ClaimsFromContext` (existing). - 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`. - 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. 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/...` Run: `cd backend && go vet ./internal/api/...`
Expected: FAIL — `s.resolveUser`/`userFromContext`/`requireProvisionedUser` undefined. Expected: FAIL — `s.resolveUser`/`userFromContext`/`requireProvisionedUser` undefined.
- [ ] **Step 3: Write `backend/internal/api/usercontext.go`** - [ ] **Step 3: Write `../../../backend/internal/api/user.go`**
```go ```go
package api package api
@@ -2790,7 +2790,7 @@ Run: `cd backend && gofmt -l internal/api/`
Expected: no output. Expected: no output.
```bash ```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' git commit -m "$(cat <<'EOF'
api: add user-resolution middleware and POST /api/setup 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** - [ ] **Step 4: Build everything and fix remaining call sites**
Run: `cd backend && go build ./... 2>&1 | head -50` 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** - [ ] **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:** **Files:**
- Modify: `backend/internal/api/profile.go` - Modify: `backend/internal/api/profile.go`
- Modify: `backend/internal/api/kinds.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/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) - 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: Keep `authResponse`, `authStatusString` unchanged; replace `recordAuthResult` and the three handlers:
@@ -3636,7 +3636,7 @@ Run: `cd backend && gofmt -l internal/api/`
Expected: no output. Expected: no output.
```bash ```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' git commit -m "$(cat <<'EOF'
api: scope profile, workout-kind, Garmin auth, and progression handlers to userID 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. **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. **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 ### Task 2: Wire `MarkGarminConnected` into the auth handlers and expose it via session/me
**Files:** **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/session.go` (`sessionMeResponse`, `handleSessionMe`)
- Modify: `backend/internal/api/api_test.go` (new tests) - Modify: `backend/internal/api/api_test.go` (new tests)
- Modify: `backend/internal/api/isolation_test.go` (new adversarial test) - 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` Run: `cd backend && go test ./internal/api/... -run 'TestSessionMe_ReportsGarminConnected|TestSessionMe_GarminConnectedStaysFalse|TestIsolation_GarminConnected' -v`
Expected: FAIL — `sessionMeResponse` has no field `GarminConnected`. Expected: FAIL — `sessionMeResponse` has no field `GarminConnected`.
- [ ] **Step 3: Update `recordAuthResult` in `auth.go`** - [ ] **Step 3: Update `recordAuthResult` in `garmin.go`**
Change the imports: 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 `handleGarminAuthLogin`, 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 `handleGarminAuthMFA`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
- [ ] **Step 4: Update `sessionMeResponse` and `handleSessionMe` in `session.go`** - [ ] **Step 4: Update `sessionMeResponse` and `handleSessionMe` in `session.go`**
@@ -446,7 +446,7 @@ Expected: `gofmt -l .` empty; everything else passes.
- [ ] **Step 7: Commit** - [ ] **Step 7: Commit**
```bash ```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" 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 - 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 before any commit. No frontend test suite exists -- verify new UI manually in a browser against
`cmd/seedsample` data. `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). what's incidentally required (none is required).
- Follow this repo's existing JSON convention exactly: hand-built response maps use snake_case - 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 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 - Modify: `backend/internal/sync/service.go:99-123` (delete `Backfill`), `:194-209` (delete
`IncrementalSync`), `:224-232` (fix `FullSync`'s doc comment) `IncrementalSync`), `:224-232` (fix `FullSync`'s doc comment)
- Modify: `backend/internal/store/syncruns.go:9-11` (delete `SyncKindBackfill`/`SyncKindIncremental`) - 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:** **Interfaces:**
- Consumes: nothing new. - Consumes: nothing new.
@@ -58,7 +58,7 @@ check, not new information).
- [ ] **Step 2: Delete `Backfill` and `IncrementalSync`, fix `FullSync`'s doc comment** - [ ] **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 ```go
// Backfill pages backward in Config.BackfillWindowDays windows until // 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` Rename and rewrite (drop the redundant SyncRun assertion -- `TestFullSync_RecordsOneCombinedSyncRun`
already covers SyncRun recording thoroughly): already covers SyncRun recording thoroughly):
@@ -261,7 +261,7 @@ Expected: all packages pass, `gofmt -l .` prints nothing.
- [ ] **Step 6: Commit** - [ ] **Step 6: Commit**
```bash ```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' git commit -m "$(cat <<'EOF'
refactor(sync): remove dead Backfill/IncrementalSync exported wrappers refactor(sync): remove dead Backfill/IncrementalSync exported wrappers
@@ -475,7 +475,7 @@ EOF
`fillActivityWorkout`) `fillActivityWorkout`)
- Modify: `backend/internal/sync/mapping.go:113-125` (`alignWorkoutTargets` signature) - Modify: `backend/internal/sync/mapping.go:113-125` (`alignWorkoutTargets` signature)
- Modify: `backend/internal/garmin/mock/mock.go` (add `WorkoutErrByID`) - 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) rewrite `TestFillPendingDetails_ReportsLiveProgress`, add new tests)
**Interfaces:** **Interfaces:**
@@ -490,7 +490,7 @@ EOF
`alignWorkoutTargets` only ever uses `len(laps)`, never any lap's actual content -- change its `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) 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 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 ```go
targets := alignWorkoutTargets(len(laps), workout) 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** - [ ] **Step 3: Change `alignWorkoutTargets`'s signature**
In `backend/internal/sync/mapping.go`, replace: In `../../../backend/internal/garmin/mapping.go`, replace:
```go ```go
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep { 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** - [ ] **Step 4: Run to verify it passes**
Run: `cd backend && go test ./internal/sync/... -run TestAlignWorkoutTargets -v` 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 `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 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 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`** - [ ] **Step 5: Add phase-aware `Progress` and update `FullSync`**
In `backend/internal/sync/service.go`, replace: In `../../../backend/internal/garmin/sync.go`, replace:
```go ```go
// Progress reports how far a currently-running (or just-finished) // 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** - [ ] **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: with:
```go ```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 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** - [ ] **Step 9: Run the full `internal/sync` test suite**
@@ -1063,7 +1063,7 @@ Expected: all pass, `gofmt -l .` prints nothing.
- [ ] **Step 11: Commit** - [ ] **Step 11: Commit**
```bash ```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' git commit -m "$(cat <<'EOF'
feat(sync): split FillPendingDetails into activities/workouts phases feat(sync): split FillPendingDetails into activities/workouts phases
@@ -1095,7 +1095,7 @@ EOF
### Task 4: Reshape `GET /api/sync/status` and update frontend types ### Task 4: Reshape `GET /api/sync/status` and update frontend types
**Files:** **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: `backend/internal/api/api_test.go` (add a new test)
- Modify: `frontend/src/types/api.ts:197-207` (`DetailFillProgress`/`SyncStatus`, `SyncRun.Kind`) - 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** - [ ] **Step 3: Update the handler**
In `backend/internal/api/sync.go`, replace `handleSyncStatus`: In `backend/internal/api/sync.go`, replace `handleGarminSyncStatus`:
```go ```go
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
@@ -1526,7 +1526,7 @@ EOF
- [ ] **Step 1: Replace the whole file** - [ ] **Step 1: Replace the whole file**
`GarminConnection.tsx` keeps its connect/MFA/Reset-all logic completely unchanged. It loses: `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 `refreshStatus`/the mount `useEffect`, and all inline sync-progress/last-sync JSX. It gains a
`showSyncModal` boolean and renders `<SyncModal>` when true. `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 ```go
mu sync.Mutex 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 ```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 ### Task 2: `DELETE /api/profile` and Garmin client/tokenstore teardown
**Files:** **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/profile.go` (add `handleDeleteProfile`)
- Modify: `backend/internal/api/api_test.go` (imports + 3 new tests) - Modify: `backend/internal/api/api_test.go` (imports + 3 new tests)
- Modify: `backend/internal/api/isolation_test.go` (1 new test) - 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` Run: `cd backend && go test ./internal/api/... -run 'TestDeleteProfile|TestIsolation_DeleteProfile' -v`
Expected: FAIL — 404s (no `DELETE /api/profile` route registered yet). 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"`): 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. **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). **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:** **Files:**
- Create: `backend/internal/applog/applog.go` - Create: `backend/internal/applog/applog.go`
- Create: `backend/internal/applog/applog_test.go` - Create: `../../../backend/internal/log/log_test.go`
**Interfaces:** **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()`). - 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` Run: `cd backend && go test ./internal/applog/... -v`
Expected: FAIL — package `internal/applog` doesn't exist yet (build failure). 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 ```go
// Package applog provides geniusrun's structured JSON logging: a // 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 ### Task 2: HTTP access log middleware
**Files:** **Files:**
- Modify: `backend/internal/config/config.go` (new `LogLevel` field) - Modify: `../../../backend/internal/config/envconfig.go` (new `LogLevel` field)
- Modify: `backend/internal/config/config_test.go` (new tests) - Modify: `../../../backend/internal/config/envconfig_test.go` (new tests)
- Modify: `backend/cmd/geniusrund/main.go` (wire up `slog.SetDefault`) - 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) - Modify: `backend/internal/api/api_test.go` (new tests)
**Interfaces:** **Interfaces:**
- Consumes: `applog.NewLogger`, `applog.WithLogger`, `applog.FromContext` (Task 1). - 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** - [ ] **Step 1: Write the failing config tests**
Append to `backend/internal/config/config_test.go`: Append to `../../../backend/internal/config/envconfig_test.go`:
```go ```go
func TestLoad_LogLevelDefaultsToInfo(t *testing.T) { 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` 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). 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: Change:
@@ -471,7 +471,7 @@ Expected: `gofmt -l .` empty; everything passes.
- [ ] **Step 9: Commit** - [ ] **Step 9: Commit**
```bash ```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" 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` ### Task 1: `wrapper.py` marks a 404 with `not_found: true`
**Files:** **Files:**
- Modify: `backend/internal/garmin/pyscript/wrapper.py` (imports, `dispatch`) - Modify: `../../../backend/internal/garmin/wrapper/wrapper.py` (imports, `dispatch`)
- Modify: `backend/internal/garmin/pyscript/tests/test_wrapper.py` (new test) - Modify: `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py` (new test)
**Interfaces:** **Interfaces:**
- Consumes: `garminconnect.GarminConnectNotFoundError` (already installed as a dependency). - Consumes: `garminconnect.GarminConnectNotFoundError` (already installed as a dependency).
@@ -48,7 +48,7 @@ missing" from "not yet fetched."
- [ ] **Step 1: Write the failing test** - [ ] **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 ```python
def test_call_marks_not_found_error_specifically(): 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` 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 (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 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 yet); `test_call_does_not_mark_other_errors_as_not_found` already passes (nothing to change for
that case). that case).
- [ ] **Step 3: Update `dispatch()`** - [ ] **Step 3: Update `dispatch()`**
In `backend/internal/garmin/pyscript/wrapper.py`, replace: In `../../../backend/internal/garmin/wrapper/wrapper.py`, replace:
```python ```python
from garminconnect import Garmin from garminconnect import Garmin
@@ -144,7 +144,7 @@ Expected: all tests PASS, including both new ones and every existing test unchan
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```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' git commit -m "$(cat <<'EOF'
feat(garmin): mark a wrapper 404 with not_found in the error response 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 ### Task 4: `fillPendingWorkouts` stops retrying a confirmed 404
**Files:** **Files:**
- Modify: `backend/internal/sync/service.go` (`fillPendingWorkouts`) - Modify: `../../../backend/internal/garmin/sync.go` (`fillPendingWorkouts`)
- Modify: `backend/internal/sync/service_test.go` (new test) - Modify: `../../../backend/internal/garmin/sync_test.go` (new test)
**Interfaces:** **Interfaces:**
- Consumes: `garmin.ErrNotFound` (Task 2), `db.SetActivityWorkoutNotFound` (Task 3). - Consumes: `garmin.ErrNotFound` (Task 2), `db.SetActivityWorkoutNotFound` (Task 3).
@@ -668,7 +668,7 @@ EOF
- [ ] **Step 1: Write the failing test** - [ ] **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`: `TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass`:
```go ```go
@@ -743,7 +743,7 @@ regardless of the error's nature).
- [ ] **Step 3: Update `fillPendingWorkouts`** - [ ] **Step 3: Update `fillPendingWorkouts`**
In `backend/internal/sync/service.go`, replace: In `../../../backend/internal/garmin/sync.go`, replace:
```go ```go
for i, a := range pending { 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** - [ ] **Step 4: Run the test to verify it passes**
@@ -817,7 +817,7 @@ Expected: all pass, `gofmt -l .` prints nothing.
- [ ] **Step 7: Commit** - [ ] **Step 7: Commit**
```bash ```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' git commit -m "$(cat <<'EOF'
fix(sync): stop retrying a workout Garmin confirms is gone 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 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 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 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 ## Wire protocol
@@ -99,5 +99,5 @@ Go's line reader uses a raised buffer size (well above the default 64KB `bufio.S
## Rollout notes ## Rollout notes
- No data migration involved — this only changes how the Go backend talks to Garmin, not what's stored. - 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). - 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: `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. - `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. - `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. - `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 ## 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. - 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. - 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 ### 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 == the existing `s.recordAuthResult(userID, res)` call, if `res.Status ==
garmin.AuthSuccess`, call `s.DB.MarkGarminConnected(r.Context(), userID)`. garmin.AuthSuccess`, call `s.DB.MarkGarminConnected(r.Context(), userID)`.
A failure here is logged and does *not* fail the HTTP response -- the 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 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. 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 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 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 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 ## Frontend design
`GarminConnection.tsx` keeps its connect/MFA/Reset-all logic and buttons untouched, but loses `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. JSX.
A new `SyncModal.tsx`: 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 ### 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 reads Garmin credentials from that user's `profile` row. To authenticate
with Garmin *before* an account exists, onboarding needs a **temporary, with Garmin *before* an account exists, onboarding needs a **temporary,
not-yet-persisted** Garmin session -- keyed by the OIDC subject (already not-yet-persisted** Garmin session -- keyed by the OIDC subject (already
@@ -45,7 +45,7 @@ finish onboarding.
### Backend: ephemeral pre-account Garmin sessions ### Backend: ephemeral pre-account Garmin sessions
New type and `Server` field (`server.go`, alongside the existing New type and `Server` field (`server.go`, alongside the existing
`userGarmin`/`userSync`/etc. per-user maps): `userClient`/`userSync`/etc. per-user maps):
```go ```go
// setupSession is a temporary, not-yet-persisted Garmin authentication // 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 spawn new" semantics as `garmin.Client.UpdateCredentials`), builds a
fresh ephemeral client whose `TokenStorePath` is namespaced under fresh ephemeral client whose `TokenStorePath` is namespaced under
`{root}/setup/{sub}` (distinct from the permanent `{root}/{userID}` `{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 collide, and so the ephemeral session's persisted Garmin tokens have
*some* home even though no user id exists yet). *some* home even though no user id exists yet).
- `recordSetupAuthResult(sub string, res garmin.AuthResult)` -- updates a - `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 resumes the already-established Garmin session instead of requiring a
fresh login. Logged, not fatal, on error. 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 (used by the Profile page's `GarminConnection.tsx` for reconnecting an
already-provisioned user) are untouched. 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 `/api/setup/complete` after a successful session creates the user with
`GarminEmail`/`GarminPassword`/`GarminConnectedAt` all set, and promotes `GarminEmail`/`GarminPassword`/`GarminConnectedAt` all set, and promotes
the *same* `*mock.Client` instance into `s.userGarmin[userID]` (asserted 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); false -- proving no redundant re-authentication happened);
`/api/setup/complete` with no prior login attempt → 409; `/api/setup/complete` with no prior login attempt → 409;
already-provisioned subject hitting either endpoint → 409; two different 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`). `client.UpdateCredentials` outside `s.mu`).
- Best-effort `os.RemoveAll` on `filepath.Join(s.GarminBase.TokenStorePath, - Best-effort `os.RemoveAll` on `filepath.Join(s.GarminBase.TokenStorePath,
strconv.FormatInt(userID, 10))` when `TokenStorePath` is configured -- 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 error; this is cleanup of an already-orphaned directory, not something
that should fail the request that already deleted the DB row. that should fail the request that already deleted the DB row.
4. Respond `204 No Content`. 4. Respond `204 No Content`.

View File

@@ -75,7 +75,7 @@ which falls back to this default.
### HTTP access log (`internal/api`) ### 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 in `Router()` (ahead of `corsMiddleware`), so it wraps every request
including unauthenticated ones (login redirect, health check) and OPTIONS including unauthenticated ones (login redirect, health check) and OPTIONS
preflights: preflights:

View File

@@ -128,7 +128,7 @@ Every current inline-error site, and what changes:
| `pages/Analysis.tsx` | Local `error` state, inline paragraph. | Same. | | `pages/Analysis.tsx` | Local `error` state, inline paragraph. | Same. |
| `components/TrainingTypesCard.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. | | `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 **Dead CSS removal:** once no `.tsx` file references them, delete the
`.error`, `.onboarding-wizard-error`, and `.login-gate-error` rules from `.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 }), body: JSON.stringify({ display_name: displayName }),
}), }),
// Auth // Garmin Auth
login: () => request<AuthResponse>("/api/auth/login", { method: "POST" }), garminLogin: () => request<AuthResponse>("/api/garmin/auth/login", { method: "POST" }),
submitMFA: (code: string) => garminMFA: (code: string) =>
request<AuthResponse>("/api/auth/mfa", { method: "POST", body: JSON.stringify({ code }) }), request<AuthResponse>("/api/garmin/auth/mfa", { method: "POST", body: JSON.stringify({ code }) }),
authStatus: () => request<AuthResponse>("/api/auth/status"), garminStatus: () => request<AuthResponse>("/api/garmin/auth/status"),
// Sync // Garmin Sync
syncRun: () => request<{ status: string }>("/api/sync/run", { method: "POST" }), garminSyncRun: () => request<{ status: string }>("/api/garmin/sync/run", { method: "POST" }),
// Deletes every synced activity (and its laps/samples/kind assignments) // Deletes every synced activity (and its laps/samples/kind assignments)
// and rewinds the backfill watermark -- destructive, gated by a // and rewinds the backfill watermark -- destructive, gated by a
// confirmation in the UI. // confirmation in the UI.
resetSync: () => request<{ status: string }>("/api/sync/reset", { method: "POST" }), garminSyncReset: () => request<{ status: string }>("/api/garmin/sync/reset", { method: "POST" }),
syncRuns: () => request<SyncRun[]>("/api/sync/runs"), garminSyncRuns: () => request<SyncRun[]>("/api/garmin/sync/runs"),
syncStatus: () => request<SyncStatus>("/api/sync/status"), garminSyncStatus: () => request<SyncStatus>("/api/garmin/sync/status"),
// Training types -- fixed taxonomy, no create/delete // Training types -- fixed taxonomy, no create/delete
listWorkoutKinds: (includeInactive = false) => 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" // it's open, so this no longer needs the "poll faster while syncing"
// dynamic interval it used to have. // dynamic interval it used to have.
useEffect(() => { useEffect(() => {
api.authStatus().then(setAuth).catch((e) => showError(String(e))); api.garminStatus().then(setAuth).catch((e) => showError(String(e)));
const interval = setInterval(() => { const interval = setInterval(() => {
api.authStatus().then(setAuth).catch(() => {}); api.garminStatus().then(setAuth).catch(() => {});
}, 6000); }, 6000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
@@ -29,7 +29,7 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
setBusy(true); setBusy(true);
try { try {
await onBeforeConnect?.(); await onBeforeConnect?.();
const result = await api.login(); const result = await api.garminLogin();
setAuth(result); setAuth(result);
setDisconnected(false); setDisconnected(false);
if (result.status === "authenticated") showSuccess("Connected to Garmin successfully."); if (result.status === "authenticated") showSuccess("Connected to Garmin successfully.");
@@ -48,7 +48,7 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
if (!code.trim()) return; if (!code.trim()) return;
setBusy(true); setBusy(true);
try { try {
const result = await api.submitMFA(code.trim()); const result = await api.garminMFA(code.trim());
setAuth(result); setAuth(result);
if (result.status === "authenticated") showSuccess("Connected to Garmin successfully."); if (result.status === "authenticated") showSuccess("Connected to Garmin successfully.");
} catch (e) { } catch (e) {
@@ -61,7 +61,7 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
async function sync() { async function sync() {
setBusy(true); setBusy(true);
try { try {
await api.syncRun(); await api.garminSyncRun();
setShowSyncModal(true); setShowSyncModal(true);
} catch (e) { } catch (e) {
showError(String(e)); showError(String(e));
@@ -76,14 +76,14 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
} }
setBusy(true); setBusy(true);
try { try {
await api.resetSync(); await api.garminSyncReset();
// Reset runs as a background sync job; wait for it to actually finish // Reset runs as a background sync job; wait for it to actually finish
// before reloading, otherwise other pages (e.g. Activities) would // before reloading, otherwise other pages (e.g. Activities) would
// still show the just-deleted activities from their own stale state. // 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) { while (status.in_progress) {
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
status = await api.syncStatus(); status = await api.garminSyncStatus();
} }
window.location.reload(); window.location.reload();
} catch (e) { } catch (e) {

View File

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